Crons and scheduled jobs
The problem
Some work runs on a timetable: a report every night, a digest every Monday. Other work runs at a set time: a reminder before a meeting, an email 3 days before a trial ends. A plain cron job on a server is easy to start, but:
- if the server is down at that minute, the job does not run;
- if the job fails half way, nobody tries again;
- one job for all users is one big job: one bad user stops the rest.
The shape of the solution
Clevix Flow has four tools for time:
| You want | Use |
|---|---|
| Run on a timetable (every night, each Monday at 07:00) | a cron trigger |
| One run at a set time, started from your code | an event with a future ts |
| Wait in the middle of a run ("3 days before the trial ends") | step.sleep / step.sleepUntil |
| Try a step again later, at a time you pick | RetryAfterError |
The code is in the repo: examples/ts/crons.ts and examples/go/crons/main.go.
Cron triggers
A cron trigger starts a run on a schedule. The engine keeps the schedule; your app does not need to be awake at that minute, only to answer when the engine calls.
const nightlyReport = flow.createFunction(
{ id: "nightly-report" },
// 02:00 every day, Paris time. Without TZ=..., the time is UTC.
{ cron: "TZ=Europe/Paris 0 2 * * *" },
async ({ event, step }) => {
// The time this run was planned for, in ms. Use it, not the clock: a run
// can start a little late, or be retried later.
const day = new Date(event.data.scheduledAt).toISOString().slice(0, 10);
const total = await step.run("build-report", () => buildReport(day));
await step.run("email-report", () => emailReport(day, total));
return { day, total };
},
);// Timer is the data of the event a cron run gets (inngest/scheduled.timer).
type Timer struct {
Cron string `json:"cron"`
ScheduledAt int64 `json:"scheduledAt"` // ms since 1970
}
func addNightlyReport(client *flow.Client) error {
_, err := flow.CreateFunction(client,
flow.FunctionOpts{ID: "nightly-report"},
// 02:00 every day, Paris time. Without TZ=..., the time is UTC.
flow.CronTrigger("TZ=Europe/Paris 0 2 * * *"),
func(ctx context.Context, in flow.Input[Timer]) (any, error) {
// The time this run was planned for. Use it, not the clock: a run can
// start a little late, or be retried later.
day := time.UnixMilli(in.Event.Data.ScheduledAt).UTC().Format("2006-01-02")
total, err := step.Run(ctx, "build-report", func(ctx context.Context) (int, error) {
return buildReport(ctx, day)
})
if err != nil {
return nil, err
}
if _, err := step.Run(ctx, "email-report", func(ctx context.Context) (bool, error) {
return true, emailReport(ctx, day, total)
}); err != nil {
return nil, err
}
return map[string]any{"day": day, "total": total}, nil
},
)
return err
}The run's event is named inngest/scheduled.timer. Its data has cron (the schedule as you wrote it) and scheduledAt (the planned time, in ms). Use scheduledAt for "which day is this run for": a run can start a little late, or be retried later, and then the clock says another time.
A function can have a cron trigger and event triggers together, for example to run each night and also when someone asks: [{ cron: "0 2 * * *" }, { event: "report/requested" }] (Go: flow.MultipleTriggers{flow.CronTrigger("0 2 * * *"), flow.EventTrigger("report/requested", nil)}).
The cron format
Five fields: minute, hour, day of month, month, day of week.
┌───────── minute (0-59)
│ ┌─────── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌─── month (1-12 or JAN-DEC)
│ │ │ │ ┌─ day of week (0-7 or SUN-SAT; 0 and 7 are Sunday)
│ │ │ │ │
* * * * *| Schedule | Means |
|---|---|
*/15 * * * * | every 15 minutes |
0 * * * * | every hour, on the hour |
30 8 * * * | every day at 08:30 |
0 9 * * MON-FRI | weekdays at 09:00 |
0 0 1 * * | the 1st of each month at midnight |
0 0 L * * | the last day of each month at midnight |
0 12 * * 5#1 | the first Friday of each month at 12:00 |
@daily | every day at midnight (also @hourly, @weekly, @monthly, @yearly) |
*is any value,,a list (1,15),-a range (9-17),/a step (*/10).- A sixth field at the front adds seconds:
*/30 * * * * *is every 30 seconds. Each one is a full run, with its own calls to your app.
Time zones
Without a zone, the schedule is in UTC. Put TZ=<zone> (or CRON_TZ=<zone>) in front, with a name from the time zone list:
TZ=Europe/Paris 0 2 * * *
CRON_TZ=Asia/Tokyo 0 9 * * MON-FRISet the zone for schedules people care about ("9 AM for our users"): the engine then follows daylight saving time. For example, TZ=Europe/Paris 0 2 * * * synced on 26 October 2026 had its next run at 01:00 UTC, which is 02:00 in Paris after the clocks went back.
Check your schedule
The engine checks every cron when you sync. A bad one is refused, and the sync answer names the function:
function f: invalid cron "0 25 * * *": CronPattern: Invalid value for hour: 25The function's page in the dashboard shows each cron with its Next run time, and has Trigger now: one extra run, now, outside the schedule. Its scheduledAt is the time you clicked.
One run per user, on a schedule
One cron run that works through every user is one big run. If it fails half way, the retry is about everything, and it hits the limit of 1000 steps per run. Better: the cron run only sends one event per user, and a second function does the work, one run per user.
// One cron run lists the users and sends one event per user. Each user then gets
// a run of their own, with its own retries.
const weeklyDigest = flow.createFunction(
{ id: "weekly-digest" },
{ cron: "TZ=UTC 0 7 * * MON" }, // Mondays at 07:00 UTC
async ({ step }) => {
const userIds = await step.run("list-users", () => listDigestUsers());
// Up to 100 events per sendEvent, so send them in chunks, each its own step.
for (let i = 0; i < userIds.length; i += 100) {
const chunk = userIds.slice(i, i + 100);
await step.sendEvent(
`send-chunk-${i / 100}`,
chunk.map((userId) => ({ name: "digest/send", data: { userId } })),
);
}
return { users: userIds.length };
},
);
const sendDigest = flow.createFunction(
{ id: "send-digest", concurrency: { limit: 10 } },
{ event: "digest/send" },
async ({ event, step }) => {
await step.run("send-digest-email", () => sendDigestEmail(event.data.userId));
},
);// One cron run lists the users and sends one event per user. Each user then
// gets a run of their own, with its own retries.
func addWeeklyDigest(client *flow.Client) error {
_, err := flow.CreateFunction(client,
flow.FunctionOpts{ID: "weekly-digest"},
flow.CronTrigger("TZ=UTC 0 7 * * MON"), // Mondays at 07:00 UTC
func(ctx context.Context, in flow.Input[Timer]) (any, error) {
userIDs, err := step.Run(ctx, "list-users", func(ctx context.Context) ([]string, error) {
return listDigestUsers(ctx)
})
if err != nil {
return nil, err
}
// Up to 100 events per step.Send, so send them in chunks, each its own step.
for i := 0; i < len(userIDs); i += 100 {
chunk := userIDs[i:min(i+100, len(userIDs))]
events := make([]flow.Event, 0, len(chunk))
for _, id := range chunk {
events = append(events, flow.Event{Name: "digest/send", Data: map[string]string{"userId": id}})
}
if _, err := step.Send(ctx, fmt.Sprintf("send-chunk-%d", i/100), events...); err != nil {
return nil, err
}
}
return map[string]int{"users": len(userIDs)}, nil
},
)
if err != nil {
return err
}
type Digest struct {
UserID string `json:"userId"`
}
_, err = flow.CreateFunction(client,
flow.FunctionOpts{ID: "send-digest", Concurrency: []flow.Concurrency{{Limit: 10}}},
flow.EventTrigger("digest/send", nil),
func(ctx context.Context, in flow.Input[Digest]) (any, error) {
return step.Run(ctx, "send-digest-email", func(ctx context.Context) (bool, error) {
return true, sendDigestEmail(ctx, in.Event.Data.UserID)
})
},
)
return err
}step.sendEvent(Go:step.Send) sends up to 100 events per call, and sends them once, even though the function runs from the top on each call. Use one step per chunk, each with its own ID.concurrency: { limit: 10 }onsend-digestkeeps 10 emails going at a time.
With Trigger now on a local engine, weekly-digest made two send-chunk steps and 120 send-digest runs, and each one completed.
How schedules behave
- Sync sets the schedule. A new cron starts from its next time after the sync. A cron you did not change keeps its timetable through a deploy. A cron you removed stops. A function you removed (archived) stops.
- Each time starts one run. The run's ID comes from the function, the cron and the time, so the same time never starts two runs.
- Missed times. If the engine could not fire for a while, it runs once, for the latest missed time, then goes on. It does not run every missed time.
- Paused function. While a function is paused (Pause on its page), its times are skipped, not saved for later. We paused an every-minute cron for one minute: that minute had no run, and the next minute ran as normal.
- Your app is down. The run waits for the app (Waiting for app). A cron function keeps only its newest waiting run; an older one ends with "A newer cron run replaced this one while the app was down".
- Runs can overlap. If a run is still going when the next time comes, a second run starts. To skip the new one, add
singleton: { mode: "skip" }(Flow control). - Flow control:
singleton,throttle,concurrencyandpriorityapply to cron runs.rateLimit,debounce,batchEventsandidempotencydo not. - No
ifon a cron trigger. The sync refuses it withfunction <id>: if is not supported on cron triggers. Check the condition in the function instead. - A spent or suspended workspace skips its cron times until it is active again.
Run once at a set time
Send the event now, with ts (Go: Timestamp) set to the time in ms. The engine makes the run at once, as Queued, and starts it at that time.
// A run that starts at a set time: send the event now, with `ts` in the future.
const sendReminder = flow.createFunction(
{ id: "send-reminder" },
{ event: "reminder/due" },
async ({ event, step }) => {
await step.run("send-reminder", () => notify(event.data.userId, event.data.text));
},
);
const app = express();
app.post("/reminders", express.json(), async (req, res) => {
const { userId, text, at } = req.body ?? {}; // at: "2026-11-02T09:00:00Z"
const ts = typeof at === "string" ? Date.parse(at) : NaN;
if (Number.isNaN(ts)) {
res.status(400).json({ error: 'at must be a date, like "2026-11-02T09:00:00Z"' });
return;
}
const { ids } = await flow.send({
name: "reminder/due",
data: { userId, text },
ts, // the run waits, queued, until this time
});
res.status(202).json({ eventId: ids[0] });
});// Reminder is the data of a reminder/due event.
type Reminder struct {
UserID string `json:"userId"`
Text string `json:"text"`
}
// A run that starts at a set time: send the event now, with Timestamp in the future.
func addSendReminder(client *flow.Client) error {
_, err := flow.CreateFunction(client,
flow.FunctionOpts{ID: "send-reminder"},
flow.EventTrigger("reminder/due", nil),
func(ctx context.Context, in flow.Input[Reminder]) (any, error) {
return step.Run(ctx, "send-reminder", func(ctx context.Context) (bool, error) {
return true, notify(ctx, in.Event.Data.UserID, in.Event.Data.Text)
})
},
)
return err
}
func reminders(client *flow.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
UserID string `json:"userId"`
Text string `json:"text"`
At time.Time `json:"at"` // "2026-11-02T09:00:00Z"
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad JSON", http.StatusBadRequest)
return
}
// A missing "at" is the zero time, not "now": refuse it.
if body.At.IsZero() {
http.Error(w, `at is required, like "2026-11-02T09:00:00Z"`, http.StatusBadRequest)
return
}
id, err := client.Send(r.Context(), flow.Event{
Name: "reminder/due",
Data: Reminder{UserID: body.UserID, Text: body.Text},
Timestamp: body.At.UnixMilli(), // the run waits, queued, until this time
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{"eventId": id})
}
}The route answers 400 when at is missing or is not a date. Check it yourself: a missing at would give a ts that is not a number (TypeScript), or the zero time, which is long past, so the run would start at once (Go).
On a local engine, a reminder sent for 20 seconds later was Queued at first, then Completed at that time.
This does not work for a function with debounce or batchEvents: they start when their own timer says. A cancelOn on the function already works while the run waits.
Wait inside a run
step.sleep pauses a run for a time; step.sleepUntil until a moment. Nothing runs while it sleeps, and no concurrency slot is held, so long sleeps are fine. Here a run sends an email 3 days before a trial ends, then ends the trial:
// Emails timed around a trial's end. Ends early if the user pays.
const trialEmails = flow.createFunction(
{
id: "trial-emails",
cancelOn: [{ event: "trial/converted", match: "data.userId" }],
},
{ event: "trial/started" },
async ({ event, step }) => {
const endsAt = Date.parse(event.data.endsAt);
const threeDays = 3 * 24 * 60 * 60 * 1000;
await step.sleepUntil("wait-until-3-days-left", endsAt - threeDays);
await step.run("send-ending-soon", () => notify(event.data.userId, "Your trial ends in 3 days"));
await step.sleepUntil("wait-until-the-end", endsAt);
await step.run("end-trial", () => endTrial(event.data.userId));
},
);// Trial is the data of a trial/started event.
type Trial struct {
UserID string `json:"userId"`
EndsAt time.Time `json:"endsAt"`
}
// Emails timed around a trial's end. Ends early if the user pays.
func addTrialEmails(client *flow.Client) error {
_, err := flow.CreateFunction(client,
flow.FunctionOpts{
ID: "trial-emails",
CancelOn: []flow.CancelOn{{Event: "trial/converted", Match: flow.StrPtr("data.userId")}},
},
flow.EventTrigger("trial/started", nil),
func(ctx context.Context, in flow.Input[Trial]) (any, error) {
trial := in.Event.Data
if err := step.SleepUntil(ctx, "wait-until-3-days-left", trial.EndsAt.Add(-72*time.Hour)); err != nil {
return nil, err
}
if _, err := step.Run(ctx, "send-ending-soon", func(ctx context.Context) (bool, error) {
return true, notify(ctx, trial.UserID, "Your trial ends in 3 days")
}); err != nil {
return nil, err
}
if err := step.SleepUntil(ctx, "wait-until-the-end", trial.EndsAt); err != nil {
return nil, err
}
return step.Run(ctx, "end-trial", func(ctx context.Context) (bool, error) {
return true, endTrial(ctx, trial.UserID)
})
},
)
return err
}cancelOnends the run whentrial/convertedcomes for the same user. On a local engine, a run that was sleeping inwait-until-the-endended as Cancelled withcancelled by event trial/converted (evt_...), and its sleep step was cancelled with it.sleepUntiltakes aDate, ms since 1970, or ISO date text (Go: atime.Time).sleeptakes text like"30s","15m","2h","1h30m","3d","2w"or ms as a number (Go: atime.Duration).- A time in the past does not wait.
Which one to use:
Event with a future ts | step.sleep in a run | |
|---|---|---|
| The run exists from | now (Queued) | now (Sleeping) |
| Can do work first | no | yes, the steps before the sleep |
| Cancel it | cancelOn, or in the dashboard | the same |
| Works with debounce and batching | no | yes |
Retry later
When an API says "come back in 20 seconds", throw RetryAfterError from the step (Go: return flow.RetryAtError(err, at)). The engine tries that step again at that time, not on its normal schedule.
const pushContacts = flow.createFunction(
{ id: "push-contacts" },
{ event: "crm/contacts-changed" },
async ({ event, step }) => {
return step.run("push-to-crm", async () => {
const answer = await crmUpdate(event.data.contactIds);
if (answer.status === 429) {
// Try this step again when the API says, not on the normal schedule.
throw new RetryAfterError("the CRM is rate limiting us", `${answer.retryAfterSeconds}s`);
}
return answer.updated;
});
},
);// Contacts is the data of a crm/contacts-changed event.
type Contacts struct {
ContactIDs []string `json:"contactIds"`
}
func addPushContacts(client *flow.Client) error {
_, err := flow.CreateFunction(client,
flow.FunctionOpts{ID: "push-contacts"},
flow.EventTrigger("crm/contacts-changed", nil),
func(ctx context.Context, in flow.Input[Contacts]) (any, error) {
return step.Run(ctx, "push-to-crm", func(ctx context.Context) (int, error) {
status, retryAfter, updated := crmUpdate(ctx, in.Event.Data.ContactIDs)
if status == http.StatusTooManyRequests {
// Try this step again when the API says, not on the normal schedule.
return 0, flow.RetryAtError(errors.New("the CRM is rate limiting us"), time.Now().Add(retryAfter))
}
return updated, nil
})
},
)
return err
}On a local engine, the first try of push-to-crm got the pretend 429, and the next try ran 20 seconds later and completed. It still counts as a try: a step out of tries fails as usual.
Limits and gotchas
- Cron on Workers with no traffic. An app syncs its functions when it starts. On Cloudflare Workers, that is on the first request after a deploy. An app with only cron functions may get no request, so send it one request at the end of your deploy. See Going to production.
- A paused function skips its times. They do not run later.
- Sleeps are as long as you like, but a run is deleted 3 days after it ends (4 days if it failed), unless you Keep it.
- Each cron run is a full run. A cron every second is a run every second, with calls to your app.
