BETTER-CONVEX

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:

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

Here's a quick reference for choosing the right approach:

ScenarioCron JobsScheduled Functions
Daily cleanup✅ Fixed schedule
Send email after signuprunAfter(0)
Subscription expirationrunAt(timestamp)
Hourly analytics✅ Fixed schedule
Reminder notifications✅ User-defined time
Database maintenance✅ Off-peak hours
Order processing delayrunAfter(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:

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

Here are 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

Now let's define the handler functions that cron jobs call:

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

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

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):

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

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

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

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

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

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

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

StateDescription
pendingNot started yet
inProgressCurrently running (actions only)
successCompleted successfully
failedHit an error
canceledCanceled via dashboard or ctx.scheduler.cancel()

Error Handling

Understanding how scheduled functions handle errors is critical for reliable systems.

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

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:

  1. Use internal functions - Prevent external access to scheduled work
  2. Store job IDs - When you need to cancel scheduled functions
  3. Check conditions - Target may be deleted before execution
  4. Consider idempotency - Scheduled functions might run multiple times
  5. Pass auth info - Auth not propagated, pass user data as arguments
  6. Use runAfter(0) - Trigger actions after mutation commits

Next Steps

On this page