Cron-based Recurring Transactions
On traditional systems, cron jobs handle recurring executions. On Flow, the FlowCron smart contract brings the same familiar cron syntax onchain. You define a schedule like 0 0 * * * (daily at midnight) using a cron expression, and your transaction runs countinously based on the schedule.
FlowCron builds on Flow's Scheduled Transactions. If you haven't worked with scheduled transactions before, check out the Scheduled Transactions documentation first.
How It Works
FlowCron provides a CronHandler resource that wraps any existing TransactionHandler. You give it a cron expression and your handler, and it takes care of scheduling and perpetuating the execution cycle. Once started, the schedule runs indefinitely without further intervention.
Under the hood, the CronHandler runs two types of transactions per tick to ensure fault tolerance:
- Executor: Runs your code. If your logic fails, only this transaction reverts.
- Keeper: Schedules the next cycle. Runs independently so the schedule survives even if your code throws an error.
This separation keeps the recurring loop alive regardless of what happens in your handler.
_10Timeline ─────────────────────────────────────────────────────────>_10 T1 T2 T3_10 │ │ │_10 ├── Executor ──────────►├── Executor ──────────►├── Executor_10 │ (runs user code) │ (runs user code) │ (runs user code)_10 │ │ │_10 └── Keeper ────────────►└── Keeper ────────────►└── Keeper_10 (schedules T2) (schedules T3) (schedules T4)_10 (+1s offset) (+1s offset) (+1s offset)
Cron Expressions
FlowCron uses the standard 5-field cron format that you may already know from Unix systems:
_10┌───────────── minute (0-59)_10│ ┌───────────── hour (0-23)_10│ │ ┌───────────── day of month (1-31)_10│ │ │ ┌───────────── month (1-12)_10│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)_10│ │ │ │ │_10* * * * *
Operators: * (any), , (list), - (range), / (step)
| Pattern | When it runs |
|---|---|
* * * * * | Every minute |
*/5 * * * * | Every 5 minutes |
0 * * * * | Top of every hour |
0 0 * * * | Daily at midnight |
0 0 * * 0 | Weekly on Sunday |
0 9-17 * * 1-5 | Hourly, 9am-5pm weekdays |
When you specify both day-of-month and day-of-week (not *), the job runs if either matches. So 0 0 15 * 0 fires on the 15th OR on Sundays.
Setup
Setting up a cron job involves creating a handler for your logic, wrapping it with FlowCron and scheduling the first tick. All transactions and scripts referenced below are available in the FlowCron GitHub repository.
Before you start, make sure you have:
- Flow CLI installed
- Some FLOW for transaction fees
- A TransactionHandler containing your recurring logic
1. Create Your Handler
Create a contract that implements the TransactionHandler interface:
_32import "FlowTransactionScheduler"_32_32access(all) contract MyRecurringTask {_32_32 access(all) resource Handler: FlowTransactionScheduler.TransactionHandler {_32_32 access(FlowTransactionScheduler.Execute)_32 fun executeTransaction(id: UInt64, data: AnyStruct?) {_32 // Your logic here_32 log("Cron fired at ".concat(getCurrentBlock().timestamp.toString()))_32 }_32_32 access(all) view fun getViews(): [Type] {_32 return [Type<StoragePath>(), Type<PublicPath>()]_32 }_32_32 access(all) fun resolveView(_ view: Type): AnyStruct? {_32 switch view {_32 case Type<StoragePath>():_32 return /storage/MyRecurringTaskHandler_32 case Type<PublicPath>():_32 return /public/MyRecurringTaskHandler_32 default:_32 return nil_32 }_32 }_32 }_32_32 access(all) fun createHandler(): @Handler {_32 return <- create Handler()_32 }_32}
Deploy this and save a handler instance to storage. See CounterTransactionHandler.cdc for a working example.
2. Wrap It with FlowCron
Create a CronHandler that wraps your handler with a cron expression:
_21transaction(_21 cronExpression: String,_21 wrappedHandlerStoragePath: StoragePath,_21 cronHandlerStoragePath: StoragePath_21) {_21 prepare(acct: auth(BorrowValue, IssueStorageCapabilityController, SaveValue) &Account) {_21 // Issue capability for wrapped handler_21 let wrappedHandlerCap = acct.capabilities.storage.issue<_21 auth(FlowTransactionScheduler.Execute) &{FlowTransactionScheduler.TransactionHandler}_21 >(wrappedHandlerStoragePath)_21_21 // Create and save the CronHandler_21 let cronHandler <- FlowCron.createCronHandler(_21 cronExpression: cronExpression,_21 wrappedHandlerCap: wrappedHandlerCap,_21 feeProviderCap: feeProviderCap,_21 schedulerManagerCap: schedulerManagerCap_21 )_21 acct.storage.save(<-cronHandler, to: cronHandlerStoragePath)_21 }_21}
See CreateCronHandler.cdc for the full transaction. Run it with:
_10flow transactions send CreateCronHandler.cdc \_10 "*/5 * * * *" \_10 /storage/MyRecurringTaskHandler \_10 /storage/MyCronHandler
3. Start the Schedule
Schedule the first executor and keeper to kick off the perpetual loop:
_31transaction(_31 cronHandlerStoragePath: StoragePath,_31 wrappedData: AnyStruct?,_31 executorPriority: UInt8,_31 executorExecutionEffort: UInt64,_31 keeperExecutionEffort: UInt64_31) {_31 prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue) &Account) {_31 // Calculate next cron tick time_31 let cronHandler = signer.storage.borrow<&FlowCron.CronHandler>(from: cronHandlerStoragePath)_31 ?? panic("CronHandler not found")_31 let executorTime = FlowCronUtils.nextTick(spec: cronHandler.getCronSpec(), afterUnix: currentTime)_31 }_31_31 execute {_31 // Schedule executor (runs your code)_31 self.manager.schedule(_31 handlerCap: self.cronHandlerCap,_31 data: self.executorContext,_31 timestamp: UFix64(self.executorTime),_31 ..._31 )_31 // Schedule keeper (schedules next cycle)_31 self.manager.schedule(_31 handlerCap: self.cronHandlerCap,_31 data: self.keeperContext,_31 timestamp: UFix64(self.keeperTime),_31 ..._31 )_31 }_31}
See ScheduleCronHandler.cdc for the full transaction. Run it with:
_10flow transactions send ScheduleCronHandler.cdc \_10 /storage/MyCronHandler \_10 nil \_10 2 \_10 500 \_10 2500
Parameters:
| Parameter | Description |
|---|---|
cronHandlerStoragePath | Path to your CronHandler |
wrappedData | Optional data passed to handler (nil or your data) |
executorPriority | 0 (High), 1 (Medium), or 2 (Low) |
executorExecutionEffort | Computation units for your code (start with 500) |
keeperExecutionEffort | Computation units for keeper (use 2500) |
4. Check Status
Query your cron job's metadata with GetCronInfo.cdc:
_10access(all) fun main(handlerAddress: Address, handlerStoragePath: StoragePath): FlowCron.CronInfo? {_10 let account = getAuthAccount<auth(BorrowValue) &Account>(handlerAddress)_10 if let handler = account.storage.borrow<&FlowCron.CronHandler>(from: handlerStoragePath) {_10 return handler.resolveView(Type<FlowCron.CronInfo>()) as? FlowCron.CronInfo_10 }_10 return nil_10}
_10flow scripts execute GetCronInfo.cdc 0xYourAddress /storage/MyCronHandler
Calculate when the next tick will occur with GetNextExecutionTime.cdc:
_10access(all) fun main(cronExpression: String, afterUnix: UInt64?): UFix64? {_10 let cronSpec = FlowCronUtils.parse(expression: cronExpression)_10 if cronSpec == nil { return nil }_10 let nextTime = FlowCronUtils.nextTick(_10 spec: cronSpec!,_10 afterUnix: afterUnix ?? UInt64(getCurrentBlock().timestamp)_10 )_10 return nextTime != nil ? UFix64(nextTime!) : nil_10}
_10flow scripts execute GetNextExecutionTime.cdc "*/5 * * * *" nil
Additional scripts for debugging:
- GetCronScheduleStatus.cdc — Returns executor/keeper IDs, timestamps, and status
- GetParsedCronExpression.cdc — Validates and parses a cron expression into a
CronSpec
Stopping a Cron Job
To stop a running cron job, cancel both the executor and keeper transactions:
_21transaction(cronHandlerStoragePath: StoragePath) {_21 prepare(signer: auth(BorrowValue, IssueStorageCapabilityController, SaveValue) &Account) {_21 let cronHandler = signer.storage.borrow<&FlowCron.CronHandler>(from: cronHandlerStoragePath)_21 ?? panic("CronHandler not found")_21_21 self.executorID = cronHandler.getNextScheduledExecutorID()_21 self.keeperID = cronHandler.getNextScheduledKeeperID()_21 }_21_21 execute {_21 // Cancel executor and keeper, receive fee refunds_21 if let id = self.executorID {_21 let refund <- self.manager.cancel(id: id)_21 self.feeReceiver.deposit(from: <-refund)_21 }_21 if let id = self.keeperID {_21 let refund <- self.manager.cancel(id: id)_21 self.feeReceiver.deposit(from: <-refund)_21 }_21 }_21}
See CancelCronSchedule.cdc for the full transaction. Run it with:
_10flow transactions send CancelCronSchedule.cdc /storage/MyCronHandler
Cancelling refunds 50% of the prepaid fees back to your account.
Contract Addresses
FlowCron is deployed on both Testnet and Mainnet:
| Contract | Testnet | Mainnet |
|---|---|---|
| FlowCron | 0x5cbfdec870ee216d | 0x6dec6e64a13b881e |
| FlowCronUtils | 0x5cbfdec870ee216d | 0x6dec6e64a13b881e |