Last updated:
Not all work should happen inside the request that triggers it. Sending a
welcome email, expiring a draft, reconciling with Stripe, or sweeping stale rows
all want to happen later: after the mutation commits, on a delay, or on a
recurring schedule. @lunora/scheduler covers both
shapes. Deferred jobs run through ctx.scheduler, recurring jobs through Cron
Triggers, both backed by a SchedulerDO you mount once per app.
Deferring a function
ctx.scheduler is available on MutationCtx and ActionCtx (not on a
read-only QueryCtx). It enqueues a function to run later:
ctx.scheduler.runAfter(delayMs, target, args?)runs after a delay.ctx.scheduler.runAt(timestampMs, target, args?)runs at an absolute epoch-millisecond time.
Both return the scheduled job's id (a string) so you can track or cancel it. The
target is either a generated reference (internal.notifications.notifySubscribers,
api.…) or the equivalent "namespace:function" path string, and it may be a
mutation or an action — not a query. A reference is the better habit: it is
checked against what actually exists, where a path string is not.
Internal functions, which aren't exposed to
clients, are the usual choice for follow-up work.
import { internal } from "@/lunora/_generated/api";
import { mutation, v } from "@/lunora/_generated/server";
export const publishPost = mutation.input({ postId: v.id("posts") }).mutation(async ({ ctx, args: { postId } }) => {
await ctx.db.patch(postId, { status: "published" });
// Fire-and-forget follow-up: notify subscribers 30s later.
await ctx.scheduler.runAfter(30_000, internal.notifications.notifySubscribers, { postId });
});runAt is the same idea pinned to a wall-clock time, e.g. expire a trial at a
stored deadline:
await ctx.scheduler.runAt(trial.endsAt, "billing:expireTrial", { userId });Scheduling an agent or workflow
runAfter / runAt also accept a generated durable-target reference
(agents.<name> from @lunora/agent or
workflows.<name> from @lunora/workflow) instead
of a "namespace:function" path. Each fire starts a fresh durable instance
of that agent/workflow, so a one-shot delayed agent run is just a scheduled
target:
import { mutation, v } from "@/lunora/_generated/server";
import { agents } from "@/lunora/_generated/api";
export const ticketOpened = mutation.input({ ticketId: v.id("tickets") }).mutation(async ({ ctx, args: { ticketId } }) => {
await ctx.db.patch(ticketId, { state: "open" });
// Kick off a durable support-agent run five minutes from now.
await ctx.scheduler.runAfter(5 * 60_000, agents.support, { prompt: "Draft a first reply", ticketId });
});The second argument's shape decides the dispatch: a string path runs a Lunora
function, a generated agents.<name> / workflows.<name> reference starts a new
durable instance with the args as its run input. For a recurring agent, give
cronJobs() the same agents.<name> target; see
recurring jobs below.
Scheduling from a mutation vs. an action
The two contexts schedule identically, but the intent differs:
- From a mutation, scheduling moves a side effect out of the transactional
read/write scope. A mutation can't call
fetch()or write to R2, so it schedules an action to do that once the write has committed. The job is held until the commit and dropped if the mutation rolls back, so it can never fire for a write that never landed — you still get the job id back synchronously, so you can store it on the row andcancelit later. - From an action, scheduling chains further async work: retry an external call later, or fan a long job out into stages. An action has no transaction to wait for, so its jobs are enqueued immediately.
import { mutation, v } from "@/lunora/_generated/server";
export const orderPlaced = mutation.input({ orderId: v.id("orders") }).mutation(async ({ ctx, args: { orderId } }) => {
await ctx.db.patch(orderId, { state: "placed" });
// The action does the external work the mutation isn't allowed to.
await ctx.scheduler.runAfter(0, "payments:charge", { orderId });
});runAfter(0, ...) is the idiomatic "do this right after I commit, but not in my
transaction" hand-off — literally so: the job reaches the scheduler after the
COMMIT, in the order the handler scheduled them.
If the job can't reach the scheduler
Everything between the COMMIT and the scheduler's acknowledgement happens
outside the transaction, so it can fail on its own: the SchedulerDO
unreachable, the shard evicted mid-hand-off. The write is durable by then and the
job is not, and rolling back is no longer on the table.
So the shard takes durable custody of each deferred job as the handler
schedules it — a row written inside the same transaction as the writes, released
once the scheduler has the job. A hand-off that fails still raises (a caller
error like a duplicate job id has to reach you), but what it can no longer do is
lose the job: the entry survives, and the shard re-offers it on a backoff ladder
until the scheduler accepts it. The retry reuses the id you were handed, so a job
that did land is recognised rather than scheduled twice, and the cancel(id) you
stored on a row keeps working throughout.
A job the scheduler refuses eight times over is parked: it stops being
retried, is logged at error naming its id and target, and is kept for a week so
you can see what was promised and never enqueued. That is the one case where a
scheduled job is dropped without the mutation being rolled back — and it is
reported rather than silent.
Inspecting and cancelling
ctx.scheduler also exposes list(), get(id), and cancel(id) for managing
pending jobs. For example, cancel a scheduled reminder when the user completes
the action first:
const { cancelled } = await ctx.scheduler.cancel(reminderId);You can also read pending jobs from a query via the _scheduled_functions
system table (ctx.db.system), which is
read-only and eventually consistent.
Recurring jobs (Cron Triggers)
For work on a fixed cadence (nightly cleanups, hourly digests) declare Cron
Triggers. They run on the platform's cron schedule, and the worker's
scheduled() handler dispatches the target straight to its shard — not
through the SchedulerDO:
import { createCronTrigger } from "@lunora/scheduler";
import { internal } from "@/lunora/_generated/api";
export const triggers = [
createCronTrigger({ schedule: "0 3 * * *", fn: internal.cleanup.cleanupOldMessages }),
createCronTrigger({ schedule: "*/15 * * * *", fn: internal.presence.sweep }),
];schedule is the cron expression and fn is a function reference from
_generated/api, not a path string — omit either and createCronTrigger throws
"createCronTrigger() requires schedule and fn". Each call returns
{ crons, wranglerJsonc, dispatcher }; paste wranglerJsonc under
triggers.crons in your wrangler.jsonc.
The CLI's wrangler validator surfaces a missing or unreachable trigger as an error during dev, so a bad reference fails fast rather than silently never firing.
A cron fire gets none of the SchedulerDO's durability. The retry ladder, exponential backoff, and dead-letter park described above belong to
ctx.scheduler jobs, which the DO owns. A cron trigger is dispatched once from the worker's scheduled() handler; a failure is logged as CRON_JOB_FAILED
and the tick is gone until the next one. Make cron handlers idempotent, and re-derive missed work from your own data rather than assuming a redelivery.
How dispatch works
A scheduled job is stored in the SchedulerDO sorted by its fire time and
dispatched on the DO's alarm. When it fires, the DO calls back into your worker
and the runtime runs the target function on the same code path as an RPC: same
context, same ctx.db. There's no separate "background worker" mental model to
keep. A scheduled mutation is just your mutation, run later.
The one difference is the caller. A scheduled or cron dispatch is
server-initiated: it carries no end-user identity, so ctx.auth.userId is
null and ctx.ip is undefined. That is also what lets it reach internal
functions. Pass whatever the job needs to know about a user in its args, and
don't gate a scheduled function on ctx.auth.
See also
- Queries & mutations:
ctx.schedulerlives on mutation/action contexts. - Actions: the kind of function you typically schedule for external work.
- File storage: a common reason to defer a write out of a mutation.
- @lunora/scheduler:
SchedulerDOsetup and the dispatch contract.