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.userId is null), so it can run access: 'internal' functions whatever their own access level. The function to run is named in your mutation's code, so treat scheduler.runAt like 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 done status 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 failed with 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