Scheduler
A mutation can schedule a function to run later, once, at a set time or after a delay. Use it for work that should happen at a particular moment (publishing a post at 9am), after a pause (sending a reminder in an hour), or in its own transaction after the current one commits.
For work that repeats on a timetable, use a cron.
Scheduling a function
The scheduler is on every mutation's handler context:
// tether/functions/posts.ts
import { mutation, z } from '../_generated/db';
import { api } from '../_generated/api';
export const schedulePublish = mutation({
args: z.object({ _id: z.string(), at: z.number() }),
handler: async ({ args, db, scheduler }) => {
const jobId = await scheduler.runAt(args.at, api.posts.publish, { _id: args._id });
await db.posts.update({ where: { _id: args._id }, data: { publishJobId: jobId } });
return { jobId };
},
});
export const publish = mutation({
access: 'internal',
args: z.object({ _id: z.string() }),
handler: async ({ args, db }) => {
await db.posts.update({ where: { _id: args._id }, data: { status: 'published' } });
},
});
| Method | Description |
|---|---|
scheduler.runAt(timestamp, fn, args?) |
Runs fn at timestamp, a Date or milliseconds since the epoch. Resolves to the job id. |
scheduler.runAfter(delayMs, fn, args?) |
Runs fn after delayMs milliseconds. Resolves to the job id. |
scheduler.cancel(jobId) |
Cancels a job that hasn't started. Does nothing if it has started, finished or already been cancelled. |
fn is the function's name as a string ('posts.publish') or its reference from the generated api (api.posts.publish). The function must be deployed when you schedule it. args is a JSON object passed to the function as its args; it is validated by the function's schema when the job runs.
A time in the past, or a delay of 0, runs the job as soon as possible after the mutation commits.
Cancelling
Keep the job id if you might need to cancel the job:
export const unschedulePublish = mutation({
args: z.object({ _id: z.string() }),
handler: async ({ args, db, scheduler }) => {
const post = await db.posts.findById(args._id);
if (post?.publishJobId) {
await scheduler.cancel(post.publishJobId);
await db.posts.update({ where: { _id: args._id }, data: { publishJobId: null } });
}
},
});
How jobs run
- Only after a commit. A job is written in the scheduling mutation's transaction. If the mutation throws, the job is never created. Cancelling is transactional too.
- On time. Jobs run when they're due, to within a few milliseconds when the server isn't busy. They don't wait for a minute tick like crons.
- As the server. A job runs with server access and no signed-in user (
ctx.auth.userIdisnull), so it can runaccess: 'internal'functions whatever their own access level. The function to run is named in your mutation's code, so treatscheduler.runAtlike calling an internal function directly: never pass a function name chosen from untrusted input, or a caller could have any deployed function run with full access. - Once. A job's own writes and its
donestatus commit in the same transaction, so its database writes happen exactly once. If the server restarts while a job is running, its writes were not committed and the job runs again when the server is back. Calls to other services (fetch) in a job may therefore happen more than once. - Not retried. A job whose function throws is marked
failedwith the error and isn't run again. To retry, schedule it again from the function, or catch the error there. - In its environment. Jobs run in the environment that scheduled them, on its data. Queries reading what a job wrote refresh, as after any other mutation.
Queries can't schedule jobs: scheduler throws when used in a query.
Job status
Each environment keeps its jobs with their status:
| Status | Meaning |
|---|---|
pending |
Waiting for its time |
running |
Running now |
done |
Finished; its return value is kept as the job's result |
failed |
Threw, or its function is no longer deployed; the error is kept |
cancelled |
Cancelled before it started |
A job's run shows up in the function's logs and execution history like any other call, plus a log entry saying whether the job finished or failed, with the job id in its data.
API
List an environment's jobs, soonest first for pending ones. Anyone who can view the project can list jobs; cancelling one needs write access, and is recorded in the audit log. OAuth2 tokens need the crons permission.
GET /api/v1/projects/{projectId}/env/{env}/scheduled-jobs?status=pending&limit=100
{
"jobs": [
{
"id": "6f1c2d3e-...",
"functionName": "posts.publish",
"args": { "_id": "post-123" },
"runAt": 1767261600000,
"status": "pending",
"scheduledBy": "posts.schedulePublish",
"createdAt": "2026-10-19T09:00:00Z",
"startedAt": null,
"completedAt": null,
"attempts": 0,
"errorMessage": null,
"result": null
}
]
}
Cancel a pending job:
POST /api/v1/projects/{projectId}/env/{env}/scheduled-jobs/{jobId}/cancel
{ "cancelled": true }
Both are also available without /env/{env} for the project's default environment.
Limits
| Limit | Value |
|---|---|
| Jobs per mutation | 1,000 |
| Pending jobs per environment | 10,000 |
| Job args | 64 KB |
| Running at once | 2 per project, 8 per server. Due jobs past that wait their turn. |
| Run time | 30 seconds, as for any function |