Skip to content

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 wantUse
Run on a timetable (every night, each Monday at 07:00)a cron trigger
One run at a set time, started from your codean 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 pickRetryAfterError

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.

ts
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 };
  },
);
go
// 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)
│ │ │ │ │
* * * * *
ScheduleMeans
*/15 * * * *every 15 minutes
0 * * * *every hour, on the hour
30 8 * * *every day at 08:30
0 9 * * MON-FRIweekdays 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#1the first Friday of each month at 12:00
@dailyevery 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-FRI

Set 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: 25

The 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.

ts
// 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));
  },
);
go
// 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 } on send-digest keeps 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, concurrency and priority apply to cron runs. rateLimit, debounce, batchEvents and idempotency do not.
  • No if on a cron trigger. The sync refuses it with function <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.

ts
// 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] });
});
go
// 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:

ts
// 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));
  },
);
go
// 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
}
  • cancelOn ends the run when trial/converted comes for the same user. On a local engine, a run that was sleeping in wait-until-the-end ended as Cancelled with cancelled by event trial/converted (evt_...), and its sleep step was cancelled with it.
  • sleepUntil takes a Date, ms since 1970, or ISO date text (Go: a time.Time). sleep takes text like "30s", "15m", "2h", "1h30m", "3d", "2w" or ms as a number (Go: a time.Duration).
  • A time in the past does not wait.

Which one to use:

Event with a future tsstep.sleep in a run
The run exists fromnow (Queued)now (Sleeping)
Can do work firstnoyes, the steps before the sleep
Cancel itcancelOn, or in the dashboardthe same
Works with debounce and batchingnoyes

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.

ts
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;
    });
  },
);
go
// 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.