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 | ❌ | ✅ 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.
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 { 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. Let's explore the key concepts.
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. Here's the pattern:
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 |
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
schedule.now- Trigger actions after mutation commits