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.
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
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".
- TypeScript
- C#
- Rust
- C++
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.
scheduled table optionOlder 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.
In [SpacetimeDB.Table(..., ScheduledAt = "...")], the value must exactly match the name of a field on that table whose type is ScheduleAt (for example, "ScheduledAt" or "scheduled_at").
using SpacetimeDB;
public static partial class Module
{
[SpacetimeDB.Table(Accessor = "Reminder", Scheduled = "SendReminder", ScheduledAt = "ScheduledAt")]
public partial struct Reminder
{
[SpacetimeDB.PrimaryKey]
[SpacetimeDB.AutoInc]
public ulong ScheduledId;
public uint UserId;
public string Message;
public ScheduleAt ScheduledAt;
}
[SpacetimeDB.Reducer]
public static void SendReminder(ReducerContext ctx, Reminder reminder)
{
// Process the scheduled reminder
}
}use spacetimedb::{reducer, table, ReducerContext, ScheduleAt, Table};
use std::time::Duration;
#[table(accessor = reminder_schedule, scheduled(send_reminder))]
pub struct Reminder {
#[primary_key]
#[auto_inc]
scheduled_id: u64,
user_id: u32,
message: String,
scheduled_at: ScheduleAt,
}
#[reducer]
fn send_reminder(ctx: &ReducerContext, reminder: Reminder) -> Result<(), String> {
// Process the scheduled reminder
Ok(())
}
#[reducer(init)]
fn init(ctx: &ReducerContext) {
ctx.db.reminder_schedule().insert(Reminder {
scheduled_id: 0,
user_id: 0,
message: "Game tick".to_string(),
scheduled_at: ScheduleAt::Interval(Duration::from_millis(50).into()),
});
}struct Reminder {
uint64_t scheduled_id;
ScheduleAt scheduled_at;
std::string message;
};
SPACETIMEDB_STRUCT(Reminder, scheduled_id, scheduled_at, message)
SPACETIMEDB_TABLE(Reminder, reminder, Public)
FIELD_PrimaryKeyAutoInc(reminder, scheduled_id)
SPACETIMEDB_SCHEDULE(reminder, 1, send_reminder) // Column 1 is scheduled_at
// Reducer invoked automatically by the scheduler
SPACETIMEDB_REDUCER(send_reminder, ReducerContext ctx, Reminder arg)
{
// Invoked automatically by the scheduler
// arg.message, arg.scheduled_at, arg.scheduled_id
LOG_INFO("Scheduled reminder: " + arg.message);
return Ok();
}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
Scheduling at Intervals
Use intervals for periodic tasks like game ticks, heartbeats, or recurring maintenance:
ScheduleAt is imported from 'spacetimedb', not from 'spacetimedb/server'. Use: import { ScheduleAt } from 'spacetimedb';
- TypeScript
- C#
- Rust
- C++
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",
});
});public static partial class Module
{
[SpacetimeDB.Reducer]
public static void SchedulePeriodicTasks(ReducerContext ctx)
{
// Schedule to run every 5 seconds
ctx.Db.Reminder.Insert(new Reminder
{
ScheduledId = 0,
Message = "Check for updates",
ScheduledAt = new ScheduleAt.Interval(TimeSpan.FromSeconds(5))
});
// Schedule to run every 100 milliseconds
ctx.Db.Reminder.Insert(new Reminder
{
ScheduledId = 0,
Message = "Game tick",
ScheduledAt = new ScheduleAt.Interval(TimeSpan.FromMilliseconds(100))
});
}
}use spacetimedb::{ScheduleAt, ReducerContext, Table};
use std::time::Duration;
#[spacetimedb::reducer]
fn schedule_periodic_tasks(ctx: &ReducerContext) {
// Schedule to run every 5 seconds
ctx.db.reminder().insert(Reminder {
scheduled_id: 0,
message: "Check for updates".to_string(),
scheduled_at: ScheduleAt::Interval(Duration::from_secs(5).into()),
});
// Schedule to run every 100 milliseconds
ctx.db.reminder().insert(Reminder {
scheduled_id: 0,
message: "Game tick".to_string(),
scheduled_at: ScheduleAt::Interval(Duration::from_millis(100).into()),
});
}// Schedule to run every 5 seconds
ctx.db[reminder].insert(Reminder{
0,
ScheduleAt(TimeDuration::from_seconds(5)),
"Check for updates"
});
// Schedule to run every 100 milliseconds
ctx.db[reminder].insert(Reminder{
0,
ScheduleAt(TimeDuration::from_millis(100)),
"Game tick"
});Scheduling at Specific Times
Use specific times for one-shot actions like sending a reminder at a particular moment or expiring content:
- TypeScript
- C#
- Rust
- C++
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!",
});
});using SpacetimeDB;
public static partial class Module
{
[SpacetimeDB.Reducer]
public static void ScheduleTimedTasks(ReducerContext ctx)
{
// Schedule for 10 seconds from now
var tenSecondsFromNow = ctx.Timestamp + new TimeDuration(10_000_000);
ctx.Db.Reminder.Insert(new Reminder
{
ScheduledId = 0,
Message = "Your auction has ended",
ScheduledAt = new ScheduleAt.Time(tenSecondsFromNow)
});
// Schedule for a specific time
var targetTime = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
ctx.Db.Reminder.Insert(new Reminder
{
ScheduledId = 0,
Message = "Happy New Year!",
ScheduledAt = new ScheduleAt.Time(targetTime)
});
}
}use spacetimedb::{ScheduleAt, ReducerContext, Table};
use std::time::Duration;
#[spacetimedb::reducer]
fn schedule_timed_tasks(ctx: &ReducerContext) {
// Schedule for 10 seconds from now
let ten_seconds_from_now = ctx.timestamp + Duration::from_secs(10);
ctx.db.reminder().insert(Reminder {
scheduled_id: 0,
message: "Your auction has ended".to_string(),
scheduled_at: ScheduleAt::Time(ten_seconds_from_now),
});
// Schedule for immediate execution (current timestamp)
ctx.db.reminder().insert(Reminder {
scheduled_id: 0,
message: "Process now".to_string(),
scheduled_at: ScheduleAt::Time(ctx.timestamp.clone()),
});
}// Schedule for 10 seconds from now
Timestamp tenSecondsFromNow = ctx.timestamp + TimeDuration::from_seconds(10);
ctx.db[reminder].insert(Reminder{
0,
ScheduleAt(tenSecondsFromNow),
"Your auction has ended"
});
// Schedule for immediate execution (current timestamp)
ctx.db[reminder].insert(Reminder{
0,
ScheduleAt(ctx.timestamp),
"Process now"
});How It Works
- Insert a row with a
ScheduleAtvalue - SpacetimeDB monitors the schedule table
- When the time arrives, the specified reducer/procedure is automatically called with the row as a parameter
- The row is typically deleted or updated by the reducer after processing
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)returnsnulland.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