Scheduling
Cron jobs and scheduled functions for background processing.
In this guide, we'll explore scheduling in Convex. You'll learn to set up cron jobs for recurring tasks, schedule one-time delayed execution, cancel scheduled functions, and check job status.
Overview
Convex provides two ways to run functions in the future:
| Type | Use For |
|---|---|
| Cron jobs | Recurring tasks on a fixed schedule |
| Scheduled functions | One-time delayed execution |
View scheduled jobs in the Dashboard under the Schedules tab.
When to Use Scheduling
Here's a quick reference for choosing the right approach:
| Scenario | Cron Jobs | Scheduled Functions |
|---|---|---|
| Daily cleanup | ✅ Fixed schedule | ❌ |
| Send email after signup | ❌ | ✅ runAfter(0) |
| Subscription expiration | ❌ | ✅ runAt(timestamp) |
| Hourly analytics | ✅ Fixed schedule | ❌ |
| Reminder notifications | ❌ | ✅ User-defined time |
| Database maintenance | ✅ Off-peak hours | ❌ |
| Order processing delay | ❌ | ✅ runAfter(5000) |
Tip: Use runAfter(0) to trigger actions immediately after a mutation commits - perfect for sending emails, webhooks, or other side effects.
Let's explore both approaches.
Cron Jobs
Cron jobs run on a fixed schedule - hourly, daily, or using cron expressions.
Setup
Create convex/functions/crons.ts to define recurring jobs:
import { cronJobs } from 'convex/server';
import { internal } from './_generated/api';
const crons = cronJobs();
// Run every 2 hours
crons.interval(
'cleanup stale data',
{ hours: 2 },
internal.crons.cleanupStaleData,
{}
);
// Run at specific times using cron syntax
crons.cron(
'daily report',
'0 9 * * *', // Every day at 9 AM UTC
internal.crons.generateDailyReport,
{}
);
export default crons;Note: Always import internal from ./_generated/api, even for functions in the same file.
Cron Expressions
Here are common cron patterns:
| Pattern | Description |
|---|---|
* * * * * | Every minute |
*/15 * * * * | Every 15 minutes |
0 * * * * | Every hour |
0 0 * * * | Daily at midnight |
0 9 * * * | Daily at 9 AM |
0 9 * * 1-5 | Weekdays at 9 AM |
0 0 1 * * | First day of month |
Format: minute hour day-of-month month day-of-week
Note: Cron jobs run in UTC timezone. Minimum interval is 1 minute.
Handler Implementation
Now let's define the handler functions that cron jobs call:
import { z } from 'zod';
import { privateMutation, privateAction } from '../lib/crpc';
import { internal } from './_generated/api';
export const cleanupStaleData = privateMutation
.input(z.object({}))
.output(z.object({ deletedCount: z.number() }))
.mutation(async ({ ctx }) => {
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
const staleSessions = await ctx
.table('session')
.filter((q) => q.lt(q.field('lastActiveAt'), thirtyDaysAgo));
for (const session of staleSessions) {
await ctx.table('session').getX(session._id).delete();
}
return { deletedCount: staleSessions.length };
});
export const generateDailyReport = privateAction
.input(z.object({}))
.output(z.null())
.action(async ({ ctx }) => {
const stats = await ctx.runQuery(internal.analytics.getDailyStats, {});
await ctx.runMutation(internal.reports.create, {
type: 'daily',
data: stats,
});
return null;
});Scheduled Functions
Scheduled functions run once at a specific time or after a delay. Let's explore the key concepts.
Key Concepts
| Concept | Description |
|---|---|
| Atomicity | Scheduling from mutations is atomic - if mutation fails, nothing is scheduled |
| Non-atomic in actions | Scheduled functions from actions run even if the action fails |
| Limits | Single function can schedule up to 1000 functions with 8MB total argument size |
| Auth not propagated | Pass user info as arguments if needed |
| Results retention | Available for 7 days after completion |
Warning: Auth context is NOT available in scheduled functions. Pass userId or other auth data as arguments when scheduling.
scheduler.runAfter
Use runAfter to schedule a function to run after a delay (in milliseconds):
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { authMutation } from '../lib/crpc';
import { internal } from './_generated/api';
export const processOrder = authMutation
.input(z.object({ orderId: zid('orders') }))
.output(z.null())
.mutation(async ({ ctx, input }) => {
await ctx.table('orders').getX(input.orderId).patch({ status: 'processing' });
// Run after 5 seconds
await ctx.scheduler.runAfter(5000, internal.orders.charge, {
orderId: input.orderId,
});
return null;
});Immediate Execution
Here's a powerful pattern: use runAfter(0) to trigger actions immediately after a mutation commits:
export const createItem = authMutation
.input(z.object({ name: z.string() }))
.output(zid('items'))
.mutation(async ({ ctx, input }) => {
const itemId = await ctx.table('items').insert({ name: input.name });
// Action runs immediately after mutation commits
await ctx.scheduler.runAfter(0, internal.items.sendNotification, {
itemId,
});
return itemId;
});This is perfect for sending emails, webhooks, or other side effects that shouldn't block the mutation.
scheduler.runAt
Use runAt to schedule a function to run at a specific Unix timestamp:
import { CRPCError } from 'better-convex/server';
export const scheduleReminder = authMutation
.input(z.object({
message: z.string(),
sendAt: z.number(), // Unix timestamp in ms
}))
.output(z.null())
.mutation(async ({ ctx, input }) => {
if (input.sendAt <= Date.now()) {
throw new CRPCError({
code: 'BAD_REQUEST',
message: 'Reminder time must be in the future',
});
}
await ctx.scheduler.runAt(input.sendAt, internal.reminders.send, {
message: input.message,
});
return null;
});Canceling Scheduled Functions
Store the job ID to cancel later. Here's the pattern:
export const createSubscription = authMutation
.input(z.object({ planId: zid('plans') }))
.output(zid('subscriptions'))
.mutation(async ({ ctx, input }) => {
// Schedule expiration in 30 days
const expirationJobId = await ctx.scheduler.runAfter(
30 * 24 * 60 * 60 * 1000,
internal.subscriptions.expire,
{ userId: ctx.userId }
);
// Store job ID for cancellation
return await ctx.table('subscriptions').insert({
userId: ctx.userId,
planId: input.planId,
expirationJobId,
});
});Then cancel using ctx.scheduler.cancel():
export const cancelSubscription = authMutation
.input(z.object({ subscriptionId: zid('subscriptions') }))
.output(z.null())
.mutation(async ({ ctx, input }) => {
const subscription = await ctx.table('subscriptions').get(input.subscriptionId);
if (!subscription) {
throw new CRPCError({ code: 'NOT_FOUND', message: 'Subscription not found' });
}
// Cancel the scheduled expiration
if (subscription.expirationJobId) {
await ctx.scheduler.cancel(subscription.expirationJobId);
}
await ctx.table('subscriptions').getX(subscription._id).delete();
return null;
});Checking Status
You can query the _scheduled_functions system table to check job status:
import { publicQuery } from '../lib/crpc';
export const getJobStatus = publicQuery
.input(z.object({ jobId: zid('_scheduled_functions') }))
.output(z.object({
name: z.string(),
scheduledTime: z.number(),
completedTime: z.number().optional(),
state: z.object({
kind: z.enum(['pending', 'inProgress', 'success', 'failed', 'canceled']),
}),
}).nullable())
.query(async ({ ctx, input }) => {
return await ctx.table.system('_scheduled_functions').get(input.jobId);
});List all pending jobs:
export const listPendingJobs = publicQuery
.input(z.object({}))
.output(z.array(z.object({
_id: zid('_scheduled_functions'),
name: z.string(),
scheduledTime: z.number(),
})))
.query(async ({ ctx }) => {
const jobs = await ctx.table
.system('_scheduled_functions')
.filter((q) => q.eq(q.field('state.kind'), 'pending'));
return jobs.map(({ _id, name, scheduledTime }) => ({
_id,
name,
scheduledTime,
}));
});Job States
| State | Description |
|---|---|
pending | Not started yet |
inProgress | Currently running (actions only) |
success | Completed successfully |
failed | Hit an error |
canceled | Canceled via dashboard or ctx.scheduler.cancel() |
Error Handling
Understanding how scheduled functions handle errors is critical for reliable systems.
Mutations
| Behavior | Description |
|---|---|
| Automatic retry | Internal Convex errors are automatically retried |
| Guaranteed execution | Once scheduled, mutations execute exactly once |
| Permanent failure | Only fails on developer errors |
Actions
| Behavior | Description |
|---|---|
| No automatic retry | Actions may have side effects, so not retried |
| At most once | Actions execute at most once |
| Manual retry | Implement retry logic if needed |
Warning: For critical actions that must succeed, implement manual retry with exponential backoff. See Error Handling for patterns.
Best Practices
Here are key practices to follow when using scheduling:
- Use internal functions - Prevent external access to scheduled work
- Store job IDs - When you need to cancel scheduled functions
- Check conditions - Target may be deleted before execution
- Consider idempotency - Scheduled functions might run multiple times
- Pass auth info - Auth not propagated, pass user data as arguments
- Use runAfter(0) - Trigger actions after mutation commits