State
Actors keep state in memory for instant reads and writes, backed by embedded SQLite for data that must survive restarts.
Types of State
There are two ways to store data in an actor, depending on whether it needs to survive restarts.
Ephemeral
Live objects on c.vars like database connections, API clients, and event emitters, or data loaded from an external source. Never persisted.
import { actor } from "rivetkit";
const counter = actor({
// Constant ephemeral value, reset each time the actor starts
vars: { count: 0, lastAccessedAt: 0 },
actions: {
increment: (c) => {
// Read and write the ephemeral vars
c.vars.lastAccessedAt = Date.now();
return ++c.vars.count;
},
getLastAccessed: (c) => c.vars.lastAccessedAt
}
});
import { actor } from "rivetkit";
const chatRoom = actor({
// Build a non-serializable emitter on each start
createVars: () => ({
messages: [] as string[],
emitter: createEventEmitter(),
}),
actions: {
broadcast: (c, text: string) => {
c.vars.messages.push(text);
// Use the ephemeral emitter
c.vars.emitter.emit("message", text);
}
}
});
// Mock event emitter for demonstration
interface EventEmitter {
on: (event: string, callback: (data: unknown) => void) => void;
emit: (event: string, data: unknown) => void;
}
function createEventEmitter(): EventEmitter {
const listeners: Record<string, ((data: unknown) => void)[]> = {};
return {
on: (event, callback) => {
listeners[event] = listeners[event] || [];
listeners[event].push(callback);
},
emit: (event, data) => {
listeners[event]?.forEach(cb => cb(data));
}
};
}
import { actor } from "rivetkit";
import { Pool } from "pg";
// One shared pool for the whole process, created once and reused by every actor
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const userActor = actor({
// Load this actor's row from the shared pool on each start
createVars: async (c) => {
const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [c.key[0]]);
return { profile: rows[0] };
},
actions: {
updateEmail: async (c, email: string) => {
await pool.query("UPDATE users SET email = $1 WHERE id = $2", [email, c.key[0]]);
}
}
});
SQLite
Use the embedded SQLite database (c.db) for data that must survive restarts, needs to be queried, requires safe schema migrations, or grows too large to hold in memory. Query it from your actions, or keep a live simulation in c.vars and save snapshots on an interval. See SQLite.
import { actor } from "rivetkit";
import { db } from "rivetkit/db";
const todoList = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS todos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL
);
`);
},
}),
actions: {
add: async (c, title: string) => {
await c.db.execute("INSERT INTO todos (title) VALUES (?)", title);
},
list: async (c) => {
return (await c.db.execute(
"SELECT id, title FROM todos ORDER BY id DESC",
)) as { id: number; title: string }[];
},
},
});
import { actor } from "rivetkit";
import { db, type RawAccess } from "rivetkit/db";
// A crash loses at most this much progress
const SNAPSHOT_INTERVAL_MS = 1_000;
const TICK_MS = 50;
type Ball = { id: string; x: number; y: number; vx: number; vy: number };
interface WorldVars {
balls: Ball[];
// `onWake` stores its loop here so `onSleep` can wait for it.
loop: Promise<void> | null;
}
interface WorldContext {
vars: WorldVars;
db: RawAccess;
aborted: boolean;
abortSignal: AbortSignal;
}
const world = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS balls (
id TEXT PRIMARY KEY,
x REAL NOT NULL,
y REAL NOT NULL,
vx REAL NOT NULL,
vy REAL NOT NULL
);
`);
},
}),
// The live simulation runs in memory, starting from the last snapshot
createVars: async (c): Promise<WorldVars> => ({
balls: await c.db.execute<Ball>("SELECT id, x, y, vx, vy FROM balls"),
loop: null,
}),
onWake: (c) => {
c.vars.loop = runWorld(c).catch((error) => {
c.log.error({ msg: "world loop failed", error });
});
},
// `c.abortSignal` fires on shutdown, so the loop saves and exits on its own
onSleep: async (c) => {
await c.vars.loop;
},
actions: {
addBall: (c, ball: Ball) => {
c.vars.balls.push(ball);
},
balls: (c) => c.vars.balls,
},
});
async function runWorld(c: WorldContext): Promise<void> {
let lastSnapshotAt = Date.now();
while (!c.aborted) {
await delay(TICK_MS, c.abortSignal);
for (const ball of c.vars.balls) {
ball.x += ball.vx;
ball.y += ball.vy;
}
if (Date.now() - lastSnapshotAt >= SNAPSHOT_INTERVAL_MS) {
await saveSnapshot(c);
lastSnapshotAt = Date.now();
}
}
// Save once more on shutdown so a graceful stop loses nothing
await saveSnapshot(c);
}
// Replace the snapshot in one transaction so a crash never leaves half of one
async function saveSnapshot(c: WorldContext): Promise<void> {
await c.db.transaction(
async (tx) => {
await tx.execute("DELETE FROM balls");
for (const ball of c.vars.balls) {
await tx.execute(
"INSERT INTO balls (id, x, y, vx, vy) VALUES (?, ?, ?, ?, ?)",
ball.id,
ball.x,
ball.y,
ball.vx,
ball.vy,
);
}
},
{ name: "save-snapshot" },
);
}
// Resolve after `ms`, or as soon as the actor starts shutting down
function delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal.aborted) {
resolve();
return;
}
const finish = () => {
clearTimeout(timer);
signal.removeEventListener("abort", finish);
resolve();
};
const timer = setTimeout(finish, ms);
signal.addEventListener("abort", finish, { once: true });
});
}
State Isolation
Each actor’s state is fully isolated. Other actors and clients can’t touch it directly; all reads and writes go through the actor’s own Actions. To coordinate state across actors, see design patterns.
Ephemeral State
c.vars holds data that exists only while the actor runs and is never saved. Use it for live objects that can’t be serialized (connections, clients, emitters) or for data loaded from an external source. Most actors use both: SQLite for durable data and vars for live objects.
createVars runs on every actor start. That makes it the place to open connections and load data each time the actor wakes.
Runtime objects
Build non-serializable objects in createVars and use them from actions:
import { actor } from "rivetkit";
const room = actor({
// EventTarget can't be serialized, so it lives in vars
createVars: () => ({
messages: [] as string[],
events: new EventTarget(),
}),
actions: {
send: (c, text: string) => {
c.vars.messages.push(text);
c.vars.events.dispatchEvent(new CustomEvent("message", { detail: text }));
}
}
});
Loading from external sources
Create the connection pool once at module scope and share it across all actors, then use createVars (which can be async) to load this actor’s data from it on each start:
import { actor } from "rivetkit";
import { Pool } from "pg";
// One shared pool for the whole process, not one per actor
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const profile = actor({
createVars: async (c) => {
const { rows } = await pool.query("SELECT * FROM users WHERE id = $1", [c.key[0]]);
return { user: rows[0] };
},
actions: {
updateEmail: async (c, email: string) => {
await pool.query("UPDATE users SET email = $1 WHERE id = $2", [email, c.key[0]]);
}
}
});
When the actor owns its data, prefer SQLite, which needs no external infrastructure.
Cleanup
vars is dropped when the actor stops, but per-actor resources like timers, subscriptions, and dedicated connections aren’t cleaned up for you. Release them in onSleep and onDestroy. A shared pool stays open for the whole process, so don’t close it per actor.
const poller = actor({
// Per-actor timer started on each wake
createVars: () => {
const vars = { ticks: 0, timer: undefined as ReturnType<typeof setInterval> | undefined };
vars.timer = setInterval(() => vars.ticks++, 5000);
return vars;
},
// Clear it before the actor sleeps or is destroyed
onSleep: (c) => clearInterval(c.vars.timer),
onDestroy: (c) => clearInterval(c.vars.timer),
actions: { /* ... */ }
});
Embedded SQLite
c.db is a SQLite database scoped to each actor and stored in the actor’s durable volume. Use it for queryable, relational, or larger-than-memory data. Queries run in the actor’s own process against pages cached next to it, and every committed write is replicated before it is acknowledged, so the database survives the actor moving to another node.
Query SQLite directly from your actions. Change a value in one statement, such as UPDATE counter SET count = count + 1 WHERE id = 1 RETURNING count, instead of reading it, changing it in JavaScript and writing it back. Actions run concurrently, so a read and a write split across an await can lose another action’s update.
Keep a copy in c.vars only for reads that are too hot to query, and only when each write sets a value instead of computing it from the copy:
import { actor } from "rivetkit";
import { db } from "rivetkit/db";
const leaderboard = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS scores (
player TEXT PRIMARY KEY,
score INTEGER NOT NULL
);
`);
},
}),
// Load the table into memory once per start
createVars: async (c) => {
const rows = (await c.db.execute("SELECT player, score FROM scores")) as {
player: string;
score: number;
}[];
return { scores: new Map(rows.map((r) => [r.player, r.score])) };
},
actions: {
top: (c) => [...c.vars.scores].sort((a, b) => b[1] - a[1]).slice(0, 10),
record: async (c, player: string, score: number) => {
c.vars.scores.set(player, score);
// Write through to SQLite
await c.db.execute(
"INSERT INTO scores (player, score) VALUES (?, ?) ON CONFLICT(player) DO UPDATE SET score = ?",
player, score, score,
);
},
},
});
For the full query API, schema migrations, transactions, and the Drizzle ORM, see:
- SQLite: raw SQL against the embedded per-actor database.
- SQLite + Drizzle: typed schema and query APIs.