The scheduler API allows you to schedule Convex functions to run at a specific time or after a delay.
Scheduling functions
The scheduler is available as ctx.scheduler in mutations and actions.
runAfter
Schedule a function to execute after a delay.
import { mutation } from "./_generated/server" ;
import { internal } from "./_generated/api" ;
import { v } from "convex/values" ;
export const createOrder = mutation ({
args: { items: v . array ( v . string ()) },
handler : async ( ctx , args ) => {
const orderId = await ctx . db . insert ( "orders" , {
items: args . items ,
status: "pending" ,
});
// Send confirmation email immediately after this mutation commits
await ctx . scheduler . runAfter ( 0 , internal . emails . sendConfirmation , {
orderId ,
});
// Archive order after 30 days
await ctx . scheduler . runAfter (
30 * 24 * 60 * 60 * 1000 ,
internal . orders . archive ,
{ orderId }
);
return orderId ;
},
});
Delay in milliseconds. Must be non-negative.
If 0, the scheduled function will be due to execute immediately after the scheduling function completes.
Maximum delay is 5 years in the future.
functionReference
FunctionReference
required
A reference to the function to schedule. Must be a mutation or action that is public or internal. import { internal } from "./_generated/api" ;
internal . tasks . process
internal . emails . send
Arguments to pass to the scheduled function.
return
Id<'_scheduled_functions'>
The ID of the scheduled function in the _scheduled_functions system table. Use this to cancel the scheduled function later if needed.
runAt
Schedule a function to execute at a specific time.
import { mutation } from "./_generated/server" ;
import { internal } from "./_generated/api" ;
import { v } from "convex/values" ;
export const scheduleReminder = mutation ({
args: {
text: v . string (),
scheduledTime: v . number (),
},
handler : async ( ctx , args ) => {
const reminderId = await ctx . db . insert ( "reminders" , {
text: args . text ,
scheduledTime: args . scheduledTime ,
});
// Schedule the reminder
const scheduledId = await ctx . scheduler . runAt (
args . scheduledTime ,
internal . reminders . send ,
{ reminderId }
);
// Store the scheduled function ID so we can cancel it later
await ctx . db . patch ( reminderId , { scheduledId });
return reminderId ;
},
});
A timestamp (milliseconds since epoch) or Date object.
If in the past, the function will be due to execute immediately after the scheduling function completes.
Cannot be more than 5 years in the past or future.
functionReference
FunctionReference
required
A reference to the function to schedule.
Arguments to pass to the scheduled function.
return
Id<'_scheduled_functions'>
The ID of the scheduled function.
cancel
Cancel a previously scheduled function.
import { mutation } from "./_generated/server" ;
import { v } from "convex/values" ;
export const cancelReminder = mutation ({
args: { reminderId: v . id ( "reminders" ) },
handler : async ( ctx , args ) => {
const reminder = await ctx . db . get ( args . reminderId );
if ( ! reminder || ! reminder . scheduledId ) {
throw new Error ( "Reminder not found or not scheduled" );
}
// Cancel the scheduled function
await ctx . scheduler . cancel ( reminder . scheduledId );
// Delete the reminder
await ctx . db . delete ( args . reminderId );
},
});
id
Id<'_scheduled_functions'>
required
The ID of the scheduled function to cancel (returned by runAfter or runAt).
Cancellation behavior:
Scheduled mutations: The mutation will either show up as “pending”, “completed”, or “failed”, but never “inProgress”. Canceling will atomically cancel it entirely or fail if it has already committed.
Scheduled actions: If the action has not started, it will not run. If already in progress, it continues running but any new functions it tries to schedule will be canceled. If already completed, canceling throws an error.
Execution guarantees
Scheduled mutations
Exactly once execution Scheduled mutations are guaranteed to execute exactly once . They are automatically retried on transient errors.
Atomic execution All writes in the mutation either succeed together or fail together.
Scheduled actions
At most once execution Scheduled actions execute at most once . They are not automatically retried and may fail due to transient errors.
Non-deterministic Actions can call external APIs and perform non-deterministic operations.
Scheduled function state
Scheduled functions are stored in the _scheduled_functions system table:
const scheduled = await ctx . db . system . get ( scheduledId );
if ( scheduled ) {
console . log ( scheduled . name ); // Function name
console . log ( scheduled . args ); // Arguments array
console . log ( scheduled . scheduledTime ); // When to run (ms since epoch)
console . log ( scheduled . state ); // Current state
}
State values:
{ kind: "pending" } - Not yet executed
{ kind: "inProgress" } - Currently executing (actions only)
{ kind: "success" } - Completed successfully
{ kind: "failed", error: string } - Failed with error
{ kind: "canceled" } - Canceled before execution
Common patterns
Email confirmation flow
import { mutation } from "./_generated/server" ;
import { internal } from "./_generated/api" ;
import { v } from "convex/values" ;
export const createAccount = mutation ({
args: { email: v . string (), name: v . string () },
handler : async ( ctx , args ) => {
const userId = await ctx . db . insert ( "users" , {
email: args . email ,
name: args . name ,
verified: false ,
});
// Send welcome email immediately
await ctx . scheduler . runAfter ( 0 , internal . emails . sendWelcome , {
userId ,
});
return userId ;
},
});
Reminder system
import { mutation , internalMutation } from "./_generated/server" ;
import { v } from "convex/values" ;
export const scheduleReminder = mutation ({
args: {
text: v . string (),
remindAt: v . number (),
},
handler : async ( ctx , args ) => {
const identity = await ctx . auth . getUserIdentity ();
if ( ! identity ) throw new Error ( "Not authenticated" );
const reminderId = await ctx . db . insert ( "reminders" , {
userId: identity . tokenIdentifier ,
text: args . text ,
remindAt: args . remindAt ,
sent: false ,
});
const scheduledId = await ctx . scheduler . runAt (
args . remindAt ,
internal . reminders . send ,
{ reminderId }
);
await ctx . db . patch ( reminderId , { scheduledId });
return reminderId ;
},
});
export const send = internalMutation ({
args: { reminderId: v . id ( "reminders" ) },
handler : async ( ctx , args ) => {
const reminder = await ctx . db . get ( args . reminderId );
if ( ! reminder || reminder . sent ) return ;
// Mark as sent
await ctx . db . patch ( args . reminderId , { sent: true });
// Send notification (via action, email service, etc.)
await ctx . scheduler . runAfter ( 0 , internal . notifications . send , {
userId: reminder . userId ,
message: reminder . text ,
});
},
});
Trial expiration
import { mutation , internalMutation } from "./_generated/server" ;
import { v } from "convex/values" ;
export const startTrial = mutation ({
args: { userId: v . id ( "users" ) },
handler : async ( ctx , args ) => {
const trialEnd = Date . now () + 14 * 24 * 60 * 60 * 1000 ; // 14 days
await ctx . db . patch ( args . userId , {
trialEndsAt: trialEnd ,
plan: "trial" ,
});
// Schedule trial expiration
await ctx . scheduler . runAt (
trialEnd ,
internal . billing . expireTrial ,
{ userId: args . userId }
);
// Send reminder 3 days before trial ends
await ctx . scheduler . runAt (
trialEnd - 3 * 24 * 60 * 60 * 1000 ,
internal . emails . sendTrialReminder ,
{ userId: args . userId }
);
},
});
export const expireTrial = internalMutation ({
args: { userId: v . id ( "users" ) },
handler : async ( ctx , args ) => {
const user = await ctx . db . get ( args . userId );
if ( ! user || user . plan !== "trial" ) return ;
await ctx . db . patch ( args . userId , { plan: "free" });
await ctx . scheduler . runAfter ( 0 , internal . emails . sendTrialExpired , {
userId: args . userId ,
});
},
});
Retry with backoff
import { action , internalAction } from "./_generated/server" ;
import { internal } from "./_generated/api" ;
import { v } from "convex/values" ;
export const processWithRetry = action ({
args: {
taskId: v . id ( "tasks" ),
attempt: v . optional ( v . number ()),
},
handler : async ( ctx , args ) => {
const attempt = args . attempt ?? 1 ;
const maxAttempts = 3 ;
try {
// Attempt processing
await fetch ( "https://api.example.com/process" , {
method: "POST" ,
body: JSON . stringify ({ taskId: args . taskId }),
});
// Mark as completed
await ctx . runMutation ( internal . tasks . markCompleted , {
taskId: args . taskId ,
});
} catch ( error ) {
if ( attempt < maxAttempts ) {
// Retry with exponential backoff
const delayMs = Math . pow ( 2 , attempt ) * 1000 ; // 2s, 4s, 8s
await ctx . scheduler . runAfter (
delayMs ,
internal . tasks . processWithRetry ,
{
taskId: args . taskId ,
attempt: attempt + 1 ,
}
);
} else {
// Mark as failed after max attempts
await ctx . runMutation ( internal . tasks . markFailed , {
taskId: args . taskId ,
error: String ( error ),
});
}
}
},
});
Best practices
Use internal functions Scheduled functions should usually be internal mutations or actions to prevent direct client calls.
Store scheduled IDs for cancellation If you need to cancel scheduled functions later, store the returned scheduled function ID in your database.
Handle idempotency Design scheduled functions to be idempotent when possible, so they can be safely retried.
Prefer mutations for guarantees Use scheduled mutations when you need exactly-once execution guarantees. Use actions for operations that call external APIs or can tolerate occasional failures.
Use delay 0 for immediate execution Schedule with delay 0 to run a function immediately after the current mutation commits, useful for separating concerns.