In-Memory State
Actors store state in memory for instant reads and writes. State can be persisted automatically or kept ephemeral.
Types of State
There are three ways to store data in an actor, depending on what it looks like and whether it needs to survive restarts.
Durable
Simple, serializable data on c.state that is automatically persisted and restored across restarts. The default starting point.
import { actor } from "rivetkit";
const counter = actor({
// Constant initial state
state: { count: 0 },
actions: {
get: (c) => c.state.count,
// Update state, changes are persisted automatically
increment: (c) => {
c.state.count += 1;
return c.state.count;
}
}
});
import { actor } from "rivetkit";
interface CounterState {
count: number;
}
const counter = actor({
// Compute the initial state when the actor is created
createState: (): CounterState => ({ count: 0 }),
actions: {
get: (c) => c.state.count,
increment: (c) => {
c.state.count += 1;
return c.state.count;
}
}
});
import { actor } from "rivetkit";
interface CounterState {
count: number;
}
const counter = actor({
// Compute the initial state from input passed at creation
createState: (c, input: { startingCount: number }): CounterState => ({
count: input.startingCount,
}),
actions: {
get: (c) => c.state.count,
increment: (c) => {
c.state.count += 1;
return c.state.count;
}
}
});
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({
state: { count: 0 },
// Constant ephemeral value, reset each time the actor starts
vars: { lastAccessedAt: 0 },
actions: {
increment: (c) => {
// Read and write the ephemeral var
c.vars.lastAccessedAt = Date.now();
return ++c.state.count;
},
getLastAccessed: (c) => c.vars.lastAccessedAt
}
});
import { actor } from "rivetkit";
const chatRoom = actor({
state: { messages: [] as string[] },
// Build a non-serializable emitter on each start
createVars: () => ({ emitter: createEventEmitter() }),
actions: {
broadcast: (c, text: string) => {
c.state.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({
state: { profile: null as Record<string, unknown> | null },
// 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
Rivet also provides an embedded SQLite database (c.db) for when your data needs to be queried, requires safe schema migrations, or grows too large to hold in memory. 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 } from "rivetkit/db";
const counter = actor({
db: db({
onMigrate: async (db) => {
await db.execute(`
CREATE TABLE IF NOT EXISTS counter (
id INTEGER PRIMARY KEY CHECK (id = 1),
count INTEGER NOT NULL
);
`);
await db.execute("INSERT OR IGNORE INTO counter (id, count) VALUES (1, 0)");
},
}),
// Load the count from SQLite into memory on every start
createVars: async (c) => {
const rows = (await c.db.execute(
"SELECT count FROM counter WHERE id = 1",
)) as { count: number }[];
return { count: rows[0].count };
},
actions: {
get: (c) => c.vars.count,
increment: async (c) => {
// Update the in-memory value and write it back to SQLite
c.vars.count += 1;
await c.db.execute("UPDATE counter SET count = ? WHERE id = 1", c.vars.count);
return c.vars.count;
},
},
});
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 share state across actors, see sharing and joining state.
Durable State
c.state lives in memory and is persisted automatically, so reads and writes have no added latency while the data still survives sleeps, restarts, upgrades, and crashes. Use it for small, simple values like counters, flags, and small maps.
createState runs once when the actor is first created. On later starts, state is loaded from storage instead of recreated. See Lifecycle.
When state saves
Mutating c.state schedules a save automatically. Rapid mutations are batched into a single write on a throttle (stateSaveInterval, default 1 second). Reads never trigger a save, saves aren’t tied to action or handler boundaries, and state is also flushed when the actor sleeps or shuts down.
To force a save mid-action, call c.saveState():
c.saveState({ immediate: true })writes immediately and resolves once the write completes.c.saveState()schedules a throttled save and returns right away, without waiting for the write.
Force an immediate save before a risky side effect so a crash can’t lose progress:
import { actor } from "rivetkit";
const checkout = actor({
state: { status: "pending" as "pending" | "charged" | "fulfilled" },
actions: {
fulfill: async (c) => {
c.state.status = "charged";
// Persist before the side effect so a crash can't undo it
await c.saveState({ immediate: true });
await chargeExternalProvider();
c.state.status = "fulfilled";
return c.state.status;
}
}
});
async function chargeExternalProvider() {
await new Promise((resolve) => setTimeout(resolve, 100));
}
Supported types
State must be serializable.
When data grows large or needs querying, store it in Embedded SQLite instead.
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: state for durable data, vars for live objects.
createVars runs on every actor start, unlike createState which runs once. 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({
state: { messages: [] as string[] },
// EventTarget can't be serialized, so it lives in vars
createVars: () => ({ events: new EventTarget() }),
actions: {
send: (c, text: string) => {
c.state.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({
state: { cachedName: "" },
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 durable state or SQLite, which need 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({
state: { ticks: 0 },
// Per-actor timer started on each wake
createVars: (c) => ({ timer: setInterval(() => c.state.ticks++, 5000) }),
// 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 on disk. Use it for queryable, relational, or larger-than-memory data. Because compute and storage live together, queries run locally with no network round trips.
A common pattern is to treat SQLite as the source of truth and keep a working copy in c.vars: load rows in createVars, serve reads from memory, and write changes back to c.db.
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.
Debugging
GET /inspector/statereturns the actor’s current state andisStateEnabled.PATCH /inspector/statelets you set state directly while debugging.- In non-dev mode, inspector endpoints require authorization.
API Reference
CreateContext- Context available during actor state creationActorContext- Context available throughout actor lifecycleActorDefinition- Interface for defining actors with state