Scheduling
Cron jobs and scheduled functions for background processing.
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
Quick reference for choosing the right approach:
| Scenario | Cron Jobs | Scheduled Functions |
|---|---|---|
| Daily cleanup | ✅ Fixed schedule | ❌ |
| Send email after signup | ❌ | ✅ schedule.now |
| Subscription expiration | ❌ | ✅ schedule.at(timestamp) |
| Hourly analytics | ✅ Fixed schedule | ❌ |
| Reminder notifications | ❌ | ✅ schedule.at(time) |
| Database maintenance | ✅ Off-peak hours | ❌ |
| Order processing delay | ❌ | ✅ schedule.after(5000) |
Tip: Use schedule.now to trigger actions immediately after a mutation commits — perfect for sending emails, webhooks, or other side effects.
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
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
Handler functions that cron jobs call:
import { z } from 'zod';
import { privateMutation, privateAction } from '../lib/crpc';
import { createAnalyticsCaller } from './generated/analytics.runtime';
import { createReportsCaller } from './generated/reports.runtime';
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;
// Index session.lastActiveAt.
const staleSessions = await ctx.orm.query.session.findMany({
where: { lastActiveAt: { lt: thirtyDaysAgo } },
limit: 1000,
});
for (const sessionRow of staleSessions) {
await ctx.orm.delete(session).where(eq(session.id, sessionRow.id));
}
return { deletedCount: staleSessions.length };
});
export const generateDailyReport = privateAction
.input(z.object({}))
.action(async ({ ctx }) => {
const analyticsCaller = createAnalyticsCaller(ctx);
const reportsCaller = createReportsCaller(ctx);
const stats = await analyticsCaller.getDailyStats({});
await reportsCaller.create({
type: 'daily',
data: stats,
});
return null;
});Scheduled Functions
Scheduled functions run once at a specific time or after a delay.
Warning: Auth context is NOT available in scheduled functions. Pass userId or other auth data as arguments when scheduling.
Key behavioral constraints: scheduling from mutations is atomic (failure cancels the schedule), limits are 1000 functions / 8MB args per function, and results are retained for 7 days. See Scheduled Function Behavior in the API Reference.
schedule.after
Use schedule.after(delayMs) to schedule a function to run after a delay (in milliseconds):
import { z } from 'zod';
import { authMutation } from '../lib/crpc';
import { createOrdersCaller } from './generated/orders.runtime';
export const processOrder = authMutation
.input(z.object({ orderId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.orm
.update(orders)
.set({ status: 'processing' })
.where(eq(orders.id, input.orderId));
// Run after 5 seconds
const caller = createOrdersCaller(ctx);
await caller.schedule.after(5000).charge({
orderId: input.orderId,
});
return null;
});schedule.now
Use schedule.now to trigger actions immediately after a mutation commits:
import { createItemsCaller } from './generated/items.runtime';
export const createItem = authMutation
.input(z.object({ name: z.string() }))
.output(z.string())
.mutation(async ({ ctx, input }) => {
const [row] = await ctx.orm
.insert(items)
.values({ name: input.name })
.returning({ id: items.id });
const itemId = row.id;
// Action runs immediately after mutation commits
const caller = createItemsCaller(ctx);
await caller.schedule.now.sendNotification({ itemId });
return itemId;
});This is perfect for sending emails, webhooks, or other side effects that shouldn't block the mutation.
schedule.at
Use schedule.at(timestamp) to schedule a function to run at a specific Unix timestamp:
import { CRPCError } from 'kitcn/server';
import { createRemindersCaller } from './generated/reminders.runtime';
export const scheduleReminder = authMutation
.input(z.object({
message: z.string(),
sendAt: z.number(), // Unix timestamp in ms
}))
.mutation(async ({ ctx, input }) => {
if (input.sendAt <= Date.now()) {
throw new CRPCError({
code: 'BAD_REQUEST',
message: 'Reminder time must be in the future',
});
}
const caller = createRemindersCaller(ctx);
await caller.schedule.at(input.sendAt).send({
message: input.message,
});
return null;
});Canceling Scheduled Functions
Store the job ID to cancel later:
import { createSubscriptionsCaller } from './generated/subscriptions.runtime';
export const createSubscription = authMutation
.input(z.object({ planId: z.string() }))
.output(z.string())
.mutation(async ({ ctx, input }) => {
// Schedule expiration in 30 days
const caller = createSubscriptionsCaller(ctx);
const expirationJobId = await caller.schedule
.after(30 * 24 * 60 * 60 * 1000)
.expire({ userId: ctx.userId });
// Store job ID for cancellation
const [row] = await ctx.orm
.insert(subscriptions)
.values({
userId: ctx.userId,
planId: input.planId,
expirationJobId,
})
.returning({ id: subscriptions.id });
return row.id;
});Then cancel using caller.schedule.cancel():
import { createSubscriptionsCaller } from './generated/subscriptions.runtime';
export const cancelSubscription = authMutation
.input(z.object({ subscriptionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const subscription = await ctx.orm.query.subscriptions.findFirst({
where: { id: input.subscriptionId },
});
if (!subscription) {
throw new CRPCError({ code: 'NOT_FOUND', message: 'Subscription not found' });
}
// Cancel the scheduled expiration
const caller = createSubscriptionsCaller(ctx);
if (subscription.expirationJobId) {
await caller.schedule.cancel(subscription.expirationJobId);
}
await ctx.orm
.delete(subscriptions)
.where(eq(subscriptions.id, subscription.id));
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: z.string() }))
.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.orm.system.get(input.jobId);
});List all pending jobs:
export const listPendingJobs = publicQuery
.input(z.object({}))
.output(z.array(z.object({
id: z.string(),
name: z.string(),
scheduledTime: z.number(),
})))
.query(async ({ ctx }) => {
const jobs = await ctx.orm.system
.query('_scheduled_functions')
.filter((q) => q.eq(q.field('state.kind'), 'pending'))
.collect();
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 caller.schedule.cancel() |
Error Handling
Mutations are automatically retried on internal Convex errors and execute exactly once. Actions are not automatically retried (they may have side effects) and execute at most once. See Error Retry Behavior in the API Reference for the full breakdown.
Warning: For critical actions that must succeed, implement manual retry with exponential backoff. See Error Handling for patterns.
API Reference
Scheduled Function Behavior
| 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 |
Error Retry Behavior
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 |