kitcn

Scheduling

Cron jobs and scheduled functions for background processing.

Overview

Convex provides two ways to run functions in the future:

TypeUse For
Cron jobsRecurring tasks on a fixed schedule
Scheduled functionsOne-time delayed execution

View scheduled jobs in the Dashboard under the Schedules tab.

When to Use Scheduling

Quick reference for choosing the right approach:

ScenarioCron JobsScheduled Functions
Daily cleanup✅ Fixed schedule
Send email after signupschedule.now
Subscription expirationschedule.at(timestamp)
Hourly analytics✅ Fixed schedule
Reminder notificationsschedule.at(time)
Database maintenance✅ Off-peak hours
Order processing delayschedule.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:

convex/functions/crons.ts
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:

PatternDescription
* * * * *Every minute
*/15 * * * *Every 15 minutes
0 * * * *Every hour
0 0 * * *Daily at midnight
0 9 * * *Daily at 9 AM
0 9 * * 1-5Weekdays 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:

convex/functions/crons.ts
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):

convex/functions/orders.ts
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:

convex/functions/items.ts
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:

convex/functions/reminders.ts
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:

convex/functions/subscriptions.ts
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():

convex/functions/subscriptions.ts
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:

convex/functions/jobs.ts
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:

convex/functions/jobs.ts
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

StateDescription
pendingNot started yet
inProgressCurrently running (actions only)
successCompleted successfully
failedHit an error
canceledCanceled 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

ConceptDescription
AtomicityScheduling from mutations is atomic - if mutation fails, nothing is scheduled
Non-atomic in actionsScheduled functions from actions run even if the action fails
LimitsSingle function can schedule up to 1000 functions with 8MB total argument size
Auth not propagatedPass user info as arguments if needed
Results retentionAvailable for 7 days after completion

Error Retry Behavior

Mutations

BehaviorDescription
Automatic retryInternal Convex errors are automatically retried
Guaranteed executionOnce scheduled, mutations execute exactly once
Permanent failureOnly fails on developer errors

Actions

BehaviorDescription
No automatic retryActions may have side effects, so not retried
At most onceActions execute at most once
Manual retryImplement retry logic if needed

Next Steps

On this page