Skip to main content
Version: 2.0.0

Schedule Tables

Tables can trigger reducers or procedures at specific times by including a special scheduling column. This allows you to schedule future actions like sending reminders, expiring items, or running periodic maintenance tasks.

Scheduling Procedures

Procedures use the same scheduling pattern as reducers: pass the onSchedule option in TypeScript, or reference the procedure name in the scheduled attribute in other languages. This is particularly useful when you need scheduled tasks that make HTTP requests or perform other side effects. See Scheduling Procedures for an example.

Defining a Schedule Table​

Why "scheduled" in the code?

The table attribute uses scheduled (with a "d") because it refers to the scheduled reducer - the function that will be scheduled for execution. The table itself is a "schedule table" that stores schedules, while the reducer it triggers is a "scheduled reducer".

In TypeScript, declare the binding on the reducer with the onSchedule option:

import { schema, table, t } from 'spacetimedb/server';

const reminder = table(
  { name: 'reminder' },
  {
    scheduledId: t.u64().primaryKey().autoInc(),
    scheduledAt: t.scheduleAt(),
    message: t.string(),
  }
);

const spacetimedb = schema({ reminder });
export default spacetimedb;

export const sendReminder = spacetimedb.reducer(
  { onSchedule: reminder },
  { arg: reminder.rowType },
  (_ctx, { arg }) => {
    // Invoked automatically by the scheduler
    // arg.message, arg.scheduledAt, arg.scheduledId
  }
);

onSchedule registers the reducer as the schedule table's target. Because the table definition does not reference the reducer, the table and the reducer can live in separate files without a circular import. A schedule table can be bound to at most one reducer or procedure; binding a second one is a schema error.

The same option works on procedures, provided the procedure's return type is t.unit(). See Scheduling Procedures.

Legacy scheduled table option

Older code declares the binding on the table instead, using a thunk that forward-references the scheduled reducer:

const reminder = table(
  { name: 'reminder', scheduled: (): any => sendReminder },
  {
    scheduledId: t.u64().primaryKey().autoInc(),
    scheduledAt: t.scheduleAt(),
    message: t.string(),
  }
);

export const sendReminder = spacetimedb.reducer({ arg: reminder.rowType }, (_ctx, { arg }) => {
  // Invoked automatically by the scheduler
});

This form still works, but the forward reference forces the table and reducer into the same file and defeats type inference (hence the (): any => cast). Prefer onSchedule in new code.

Inserting Schedules​

To schedule an action, insert a row into the schedule table with a scheduled_at value. You can schedule actions to run:

  • At intervals - Execute repeatedly at fixed time intervals (e.g., every 5 seconds)
  • At specific times - Execute once at an absolute timestamp

Interval schedules are anchored to their intended execution times. In the current implementation, if the database is busy or offline long enough to miss one or more interval ticks, SpacetimeDB schedules the next future tick rather than running missed ticks back-to-back or drifting the schedule from the delayed execution time.

Scheduling at Intervals​

Use intervals for periodic tasks like game ticks, heartbeats, or recurring maintenance:

TypeScript: ScheduleAt import

ScheduleAt is imported from 'spacetimedb', not from 'spacetimedb/server'. Use: import { ScheduleAt } from 'spacetimedb';

import { ScheduleAt } from 'spacetimedb';
import { schema } from 'spacetimedb/server';
const spacetimedb = schema({ reminder }); // reminder table defined above
export default spacetimedb;

export const schedulePeriodicTasks = spacetimedb.reducer((ctx) => {
  // Schedule to run every 5 seconds (5,000,000 microseconds)
  ctx.db.reminder.insert({
    scheduledId: 0n,
    scheduledAt: ScheduleAt.interval(5_000_000n),
    message: "Check for updates",
  });

  // Schedule to run every 100 milliseconds
  ctx.db.reminder.insert({
    scheduledId: 0n,
    scheduledAt: ScheduleAt.interval(100_000n), // 100ms in microseconds
    message: "Game tick",
  });
});

Scheduling at Specific Times​

Use specific times for one-shot actions like sending a reminder at a particular moment or expiring content:

import { ScheduleAt } from 'spacetimedb';
import { schema } from 'spacetimedb/server';
const spacetimedb = schema({ reminder }); // reminder table defined above
export default spacetimedb;

export const scheduleTimedTasks = spacetimedb.reducer((ctx) => {
  // Schedule for 10 seconds from now
  const tenSecondsFromNow = ctx.timestamp.microsSinceUnixEpoch + 10_000_000n;
  ctx.db.reminder.insert({
    scheduledId: 0n,
    scheduledAt: ScheduleAt.time(tenSecondsFromNow),
    message: "Your auction has ended",
  });

  // Schedule for a specific Unix timestamp (microseconds since epoch)
  const targetTime = 1735689600_000_000n; // Jan 1, 2025 00:00:00 UTC
  ctx.db.reminder.insert({
    scheduledId: 0n,
    scheduledAt: ScheduleAt.time(targetTime),
    message: "Happy New Year!",
  });
});

How It Works​

  1. Insert a row with a ScheduleAt value
  2. SpacetimeDB monitors the schedule table
  3. When the time arrives, the specified reducer/procedure is automatically called with the row as a parameter
  4. The row is typically deleted or updated by the reducer after processing

For interval schedules, the current implementation calculates the next run from the previous intended run time. Missed interval ticks are skipped, so a delayed scheduled reducer or procedure resumes on the next future interval boundary.

When multiple schedule rows have already expired by the time SpacetimeDB observes them, their dispatch order is not guaranteed to match scheduled_at order. If application behavior depends on ordering, store an explicit sequence or timestamp in the scheduled row and handle ordering in your module logic.

Row Lifecycle​

SpacetimeDB passes the schedule row to the scheduled reducer or procedure as an argument. One-shot schedule rows are removed at different times depending on the kind of function being called:

  • Scheduled procedures delete the row before execution, so schedule_table.find(scheduled_id) returns null and .update() fails.
  • Scheduled reducers delete the row after execution, so the row is visible in the schedule table while the reducer runs.
  • Interval schedules are never deleted automatically. Only one-shot schedules are removed after they run.

Use Cases​

  • Reminders and notifications - Schedule messages to be sent at specific times
  • Expiring content - Automatically remove or archive old data
  • Delayed actions - Queue up actions to execute after a delay
  • Periodic tasks - Schedule repeating maintenance or cleanup operations
  • Game mechanics - Timer-based gameplay events (building completion, energy regeneration, etc.)

Next Steps​

  • Learn about Reducers to handle scheduled actions
  • Explore Procedures for scheduled execution patterns