Crash Course
Learn the core Rivet Actors model through durable state, typed actions, realtime events, connections, and client integration.
Features
- Long-Lived, Stateful Compute: Each unit of compute is like a tiny server that remembers things between requests – no need to re-fetch data from a database or worry about timeouts. Like AWS Lambda, but with memory and no timeouts.
- Blazing-Fast Reads & Writes: State lives in memory next to your code, so reads are instant and writes return without waiting on a separate database. Every actor’s data is persisted to a durable, replicated volume, so it survives restarts and moves between nodes.
- Realtime: Update state and broadcast changes in realtime with WebSockets. No external pub/sub systems, no polling – just built-in low-latency events.
- Infinitely Scalable: Automatically scale from zero to millions of concurrent actors. Pay only for what you use with instant scaling and no cold starts.
- Fault Tolerant: Built-in error handling and recovery. Actors automatically restart on failure while preserving state integrity and continuing operations.
When to Use Rivet Actors
- AI agents & sandboxes: multi-step toolchains, conversation memory, sandbox orchestration.
- Multiplayer or collaborative apps: CRDT docs, shared cursors, realtime dashboards, chat.
- Workflow automation: background jobs, cron, rate limiters, durable queues, backpressure control.
- Data-intensive backends: geo-distributed or per-tenant databases, in-memory caches, sharded SQL.
- Networking workloads: WebSocket servers, custom protocols, local-first sync, edge fanout.
Minimal Project
Backend
index.ts
import { actor, event, setup } 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)");
},
}),
events: {
count: event<number>(),
},
actions: {
increment: async (c, amount: number) => {
const [{ count }] = await c.db.execute<{ count: number }>(
"UPDATE counter SET count = count + ? WHERE id = 1 RETURNING count",
amount,
);
c.broadcast("count", count);
return count;
},
},
});
export const registry = setup({
use: { counter },
});
registry.start();
Client Docs
Use the client SDK that matches your app:
Actor Quick Reference
State
Store data that must survive restarts, crashes, and deployments in the actor’s embedded SQLite database. Change it with one atomic statement such as UPDATE ... RETURNING instead of reading a value and writing it back, because concurrent actions can interleave at every await. The database is persisted on Rivet Cloud or Rivet self-hosted, so it survives restarts if the current process crashes or exits.
Static Initial Value
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)");
},
}),
actions: {
increment: async (c) => {
// Update and read the count in one atomic statement
const [{ count }] = await c.db.execute<{ count: number }>(
"UPDATE counter SET count = count + 1 WHERE id = 1 RETURNING count",
);
return count;
},
},
});
Seed From Input
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)",
);
},
}),
// Seed the initial row from input once, when the actor is first created
onCreate: async (c, input: { start?: number }) => {
await c.db.execute("INSERT INTO counter (id, count) VALUES (1, ?)", input.start ?? 0);
},
actions: {
increment: async (c) => {
const [{ count }] = await c.db.execute<{ count: number }>(
"UPDATE counter SET count = count + 1 WHERE id = 1 RETURNING count",
);
return count;
},
},
});
Keys
Keys uniquely identify actor instances. Use compound keys (arrays) for hierarchical addressing:
import { actor, setup } from "rivetkit";
import { createClient } from "rivetkit/client";
const chatRoom = actor({
actions: {
getRoomInfo: (c) => ({ org: c.key[0], room: c.key[1] }),
},
});
const registry = setup({ use: { chatRoom } });
const client = createClient<typeof registry>("http://localhost:6420");
// Compound key: [org, room]
client.chatRoom.getOrCreate(["org-acme", "general"]);
// Access key inside actor via c.key
Don’t build keys with string interpolation like "org:${userId}" when userId contains user data. Use arrays instead to prevent key injection attacks.
Input
Pass initialization data when creating actors. Input is only available in onCreate, so store it in SQLite if you need it later.
import { actor, setup } from "rivetkit";
import { createClient } from "rivetkit/client";
import { db } from "rivetkit/db";
const game = actor({
db: db({
onMigrate: async (db) => {
await db.execute(
"CREATE TABLE IF NOT EXISTS game (id INTEGER PRIMARY KEY CHECK (id = 1), mode TEXT NOT NULL)",
);
},
}),
onCreate: async (c, input: { mode: string }) => {
// Store input in SQLite for later access
await c.db.execute("INSERT INTO game (id, mode) VALUES (1, ?)", input.mode);
},
actions: {
getMode: async (c) => {
const rows = (await c.db.execute("SELECT mode FROM game WHERE id = 1")) as {
mode: string;
}[];
return rows[0].mode;
},
},
});
const registry = setup({ use: { game } });
const client = createClient<typeof registry>("http://localhost:6420");
// Client usage
const gameHandle = client.game.getOrCreate(["game-1"], {
createWithInput: { mode: "ranked" },
});
Temporary Variables
Temporary data that doesn’t survive restarts. Use for non-serializable objects (event emitters, connections, etc).
Static Initial Vars
Actions
Actions are the primary way clients and other actors communicate with an actor.
import { actor } from "rivetkit";
const counter = actor({
vars: { count: 0 },
actions: {
increment: (c, amount: number) => (c.vars.count += amount),
getCount: (c) => c.vars.count,
},
});
Actions can be nested to group related behavior. For example, handle.users.add("Ada") calls the low-level action name users.add.
Events & Broadcasts
Events enable real-time communication from actors to connected clients.
import { actor, event } from "rivetkit";
const chatRoom = actor({
events: {
newMessage: event<{ text: string }>(),
},
actions: {
sendMessage: (c, text: string) => {
// Broadcast to ALL connected clients
c.broadcast("newMessage", { text });
},
},
});
Connections
Access the current connection via c.conn or all connected clients via c.conns. Use c.conn.id to securely identify who is calling an action. c.conn is only available for actions invoked through a connected client; stateless actor-handle calls run without a connection, so guard against that. Track per-connection data in c.vars keyed by conn.id: add it in onConnect from the parameters the client passed, and remove it in onDisconnect.
Generated Per Connection
import { actor } from "rivetkit";
const chatRoom = actor({
// Per-connection data, keyed by connection ID
createVars: () => ({ visitors: new Map<string, number>() }),
onConnect: (c, conn) => {
c.vars.visitors.set(conn.id, Math.random());
},
onDisconnect: (c, conn) => {
c.vars.visitors.delete(conn.id);
},
actions: {
whoAmI: (c) => c.vars.visitors.get(c.conn.id),
},
});
From Connection Params
import { actor } from "rivetkit";
const chatRoom = actor({
// Per-connection data, keyed by connection ID
createVars: () => ({ users: new Map<string, { userId: string }>() }),
// Params are passed from the client when it connects
onConnect: (c, conn) => {
const params = conn.params as { userId: string };
c.vars.users.set(conn.id, { userId: params.userId });
},
onDisconnect: (c, conn) => {
c.vars.users.delete(conn.id);
},
actions: {
// Access the current connection's data and params
whoAmI: (c) => ({
user: c.vars.users.get(c.conn.id),
params: c.conn.params,
}),
// Iterate all connections with c.conns
notifyOthers: (c, text: string) => {
for (const conn of c.conns.values()) {
if (conn !== c.conn) conn.send("notification", { text });
}
},
},
});
Background Tasks
Start long-running background work, such as a tick loop, from onWake. onWake runs on every start, so the task restarts after a sleep or crash. The task stops when c.abortSignal fires, and onSleep waits for it. A background task does not keep the actor awake, so wrap work that must finish in c.keepAwake(promise).
import { type ActorContextOf, actor, event } from "rivetkit";
const ticker = actor({
events: {
tick: event<number>(),
},
vars: {
ticks: 0,
// `onWake` stores its loop here so `onSleep` can wait for it.
loop: null as Promise<void> | null,
},
// `onWake` runs on every start, so the loop restarts after a sleep or crash
onWake: (c) => {
c.vars.loop = tickLoop(c).catch((error) => {
c.log.error({ msg: "tick loop failed", error });
});
},
// `c.abortSignal` fires before `onSleep`, so the loop is already exiting
onSleep: async (c) => {
await c.vars.loop;
},
actions: {
getTicks: (c) => c.vars.ticks,
},
});
async function tickLoop(c: ActorContextOf<typeof ticker>): Promise<void> {
while (!c.aborted) {
c.vars.ticks += 1;
c.broadcast("tick", c.vars.ticks);
await delay(1_000, c.abortSignal);
}
}
// 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 });
});
}
Workflows
Set run to a workflow for durable, replayable multi-step execution. Each completed step is recorded, so the workflow resumes where it left off after a sleep, crash, or upgrade.
import { actor } from "rivetkit";
import { db } from "rivetkit/db";
import { workflow } from "rivetkit/workflow";
const onboarding = actor({
db: db({
onMigrate: async (db) => {
await db.execute(
"CREATE TABLE IF NOT EXISTS account (id INTEGER PRIMARY KEY CHECK (id = 1), email TEXT NOT NULL)",
);
},
}),
onCreate: async (c, input: { email: string }) => {
await c.db.execute("INSERT INTO account (id, email) VALUES (1, ?)", input.email);
},
run: workflow(async (ctx) => {
const email = await ctx.step("load-email", async (step) => {
const rows = await step.db.execute<{ email: string }>(
"SELECT email FROM account WHERE id = 1",
);
return rows[0].email;
});
// Completed steps are recorded, so a restart resumes after the last one
await ctx.step("send-welcome", async () => {
await sendEmail(email, "Welcome!");
});
await ctx.sleep("wait-one-day", 24 * 60 * 60 * 1000);
await ctx.step("send-tips", async () => {
await sendEmail(email, "Getting started tips");
});
}),
});
async function sendEmail(to: string, subject: string): Promise<void> {
const res = await fetch("https://api.example.com/email", {
method: "POST",
body: JSON.stringify({ to, subject }),
});
if (!res.ok) throw new Error(`Email failed: ${res.status}`);
}
Actor-to-Actor Communication
Actors can call other actors using c.client().
import { actor, setup } from "rivetkit";
const inventory = actor({
vars: { stock: 100 },
actions: {
reserve: (c, amount: number) => {
c.vars.stock -= amount;
},
},
});
const order = actor({
actions: {
process: async (c) => {
const client = c.client<typeof registry>();
await client.inventory.getOrCreate(["main"]).reserve(1);
},
},
});
const registry = setup({ use: { inventory, order } });
Schedule & Cron
Run one-shot actions after a delay or at a specific time. Use named cron jobs for calendar schedules and fixed intervals. Both persist across sleep, restarts, upgrades, and crashes.
import { actor, event } from "rivetkit";
const reminder = actor({
vars: { message: "Time to check your tasks." },
events: {
reminder: event<{ message: string }>(),
},
onCreate: async (c) => {
// Install initial schedules once, when this actor is first created.
await c.schedule.after(30_000, "sendReminder");
await c.cron.every({
name: "reminder-check",
interval: 60_000,
action: "sendReminder",
});
},
actions: {
sendReminder: (c) => {
c.broadcast("reminder", { message: c.vars.message });
},
},
});
Destroying Actors
Permanently delete an actor and its state using c.destroy().
import { actor } from "rivetkit";
import { db } from "rivetkit/db";
const userAccount = actor({
db: db({
onMigrate: async (db) => {
await db.execute(
"CREATE TABLE IF NOT EXISTS account (id INTEGER PRIMARY KEY CHECK (id = 1), email TEXT NOT NULL, name TEXT NOT NULL)",
);
},
}),
onDestroy: async (c) => {
const rows = (await c.db.execute("SELECT email FROM account WHERE id = 1")) as {
email: string;
}[];
console.log(`Account ${rows[0]?.email} deleted`);
},
actions: {
deleteAccount: (c) => {
c.destroy();
},
},
});
Lifecycle Hooks
Actors support hooks for initialization, background processing, connections, and networking. Start long-lived background tasks from onWake, and use c.aborted or c.abortSignal to stop them on shutdown.
import { actor, event } from "rivetkit";
import { db } from "rivetkit/db";
interface RoomInput {
roomName: string;
}
interface ConnInfo {
userId: string;
joinedAt: number;
}
const chatRoom = actor({
events: {
usersChanged: event<string[]>(),
},
// Database & vars initialization
db: db({
onMigrate: async (db) => {
await db.execute(
"CREATE TABLE IF NOT EXISTS room (id INTEGER PRIMARY KEY CHECK (id = 1), name TEXT NOT NULL)",
);
},
}),
createVars: () => ({
startTime: Date.now(),
users: new Map<string, ConnInfo>(),
}),
// Actor lifecycle
onCreate: async (c, input: RoomInput) => {
await c.db.execute("INSERT INTO room (id, name) VALUES (1, ?)", input.roomName);
console.log("created", c.key);
},
onDestroy: (c) => console.log("destroyed"),
onWake: (c) => console.log("actor started"),
onSleep: (c) => console.log("actor sleeping"),
// Connection lifecycle
onBeforeConnect: (c, params) => {
/* validate auth */
},
onConnect: (c, conn) => {
const { userId } = conn.params as { userId: string };
c.vars.users.set(conn.id, { userId, joinedAt: Date.now() });
console.log("connected:", userId);
c.broadcast("usersChanged", [...c.vars.users.values()].map((u) => u.userId));
},
onDisconnect: (c, conn) => {
console.log("disconnected:", c.vars.users.get(conn.id)?.userId);
c.vars.users.delete(conn.id);
c.broadcast("usersChanged", [...c.vars.users.values()].map((u) => u.userId));
},
// Networking
onRequest: (c, req) => new Response(JSON.stringify([...c.vars.users.values()])),
onWebSocket: (c, socket) => socket.addEventListener("message", console.log),
// Response transformation
onBeforeActionResponse: <Out>(
c: unknown,
name: string,
args: unknown[],
output: Out,
): Out => output,
actions: {},
});
Context Types
When writing helper functions outside the actor definition, use *ContextOf<typeof myActor> to extract the correct context type. Helpers like ActionContextOf, CreateContextOf, ConnContextOf, and ConnInitContextOf are exported from "rivetkit". Do not manually define your own context interface. Always derive it from the actor definition.
import { actor, ActionContextOf } from "rivetkit";
const gameRoom = actor({
vars: { players: [] as string[], score: 0 },
actions: {
addPlayer: (c, playerId: string) => {
validatePlayer(c, playerId);
c.vars.players.push(playerId);
},
},
});
// Good: derive context type from actor definition
function validatePlayer(c: ActionContextOf<typeof gameRoom>, playerId: string) {
if (c.vars.players.includes(playerId)) {
throw new Error("Player already in room");
}
}
// Bad: don't manually define context types like this
// type MyContext = { vars: { players: string[] }; ... };
Errors
Use UserError to throw errors that are safely returned to clients. Pass metadata to include structured data. Other errors are converted to generic “internal error” for security.
Actor
import { actor, UserError } from "rivetkit";
const user = actor({
vars: { username: "" },
actions: {
updateUsername: (c, username: string) => {
if (username.length < 3) {
throw new UserError("Username too short", {
code: "username_too_short",
metadata: { minLength: 3, actual: username.length },
});
}
c.vars.username = username;
},
},
});
Client
import { actor, setup, UserError } from "rivetkit";
import { createClient, ActorError } from "rivetkit/client";
const user = actor({
vars: { username: "" },
actions: {
updateUsername: (c, username: string) => {
if (username.length < 3) {
throw new UserError("Username too short", {
code: "username_too_short",
metadata: { minLength: 3, actual: username.length },
});
}
c.vars.username = username;
},
},
});
const registry = setup({ use: { user } });
const client = createClient<typeof registry>("http://localhost:6420");
try {
await client.user.getOrCreate([]).updateUsername("ab");
} catch (error) {
if (error instanceof ActorError) {
console.log(error.code); // "username_too_short"
console.log(error.metadata); // { minLength: 3, actual: 2 }
}
}
Low-Level HTTP & WebSocket Handlers
For custom protocols or integrating libraries that need direct access to HTTP Request/Response or WebSocket connections, use onRequest and onWebSocket.
HTTP Handler Documentation · WebSocket Handler Documentation
Icons & Names
Customize how actors appear in the UI with display names and icons. It’s recommended to always provide a name and icon to actors in order to make them easier to distinguish in the dashboard.
import { actor } from "rivetkit";
const chatRoom = actor({
options: {
name: "Chat Room",
icon: "💬", // or FontAwesome: "comments", "chart-line", etc.
},
// ...
});
Client Documentation
Find the full client guides here:
Common Patterns
Actors scale naturally through isolated state and message-passing. Structure your applications with these patterns:
Actor Per Entity
Create one actor per user, document, or room. Use compound keys to scope entities:
import { createClient } from "rivetkit/client";
import type { registry } from "./index";
const client = createClient<typeof registry>("http://localhost:6420");
// Single key: one actor per user
client.user.getOrCreate(["user-123"]);
// Compound key: document scoped to an organization
client.document.getOrCreate(["org-acme", "doc-456"]);
Coordinator & Data Actors
Data actors handle core logic (chat rooms, game sessions, user data). Coordinator actors track and manage collections of data actors. think of them as an index.
import { actor, setup } from "rivetkit";
import { db } from "rivetkit/db";
// Coordinator: tracks chat rooms within an organization
export const chatRoomList = actor({
db: db({
onMigrate: async (db) => {
await db.execute("CREATE TABLE IF NOT EXISTS rooms (name TEXT PRIMARY KEY)");
},
}),
actions: {
addRoom: async (c, name: string) => {
// Create the chat room actor
const client = c.client<typeof registry>();
await client.chatRoom.create([c.key[0], name]);
await c.db.execute("INSERT INTO rooms (name) VALUES (?)", name);
},
listRooms: async (c) => {
const rows = (await c.db.execute("SELECT name FROM rooms")) as { name: string }[];
return rows.map((row) => row.name);
},
},
});
// Data actor: handles a single chat room
export const chatRoom = actor({
db: db({
onMigrate: async (db) => {
await db.execute(
"CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL)",
);
},
}),
actions: {
send: async (c, msg: string) => {
await c.db.execute("INSERT INTO messages (text) VALUES (?)", msg);
},
},
});
export const registry = setup({ use: { chatRoomList, chatRoom } });
registry.start();
import { createClient } from "rivetkit/client";
import type { registry } from "./index";
const client = createClient<typeof registry>("http://localhost:6420");
// Coordinator per org
const coordinator = client.chatRoomList.getOrCreate(["org-acme"]);
await coordinator.addRoom("general");
await coordinator.addRoom("random");
// Access chat rooms created by coordinator
client.chatRoom.get(["org-acme", "general"]);
Concurrent Actions
- Actions run concurrently and interleave at every
await, so never read a value,awaitsomething else, and then write it back. - Make each update a single SQL statement, such as
UPDATE ... RETURNING, or wrap related writes inc.db.transactionwith a staticname. - Do not
awaitother actors,fetch, or other network work inside a transaction. It blocks the actor’s SQL until it finishes.
Authentication, Security, & CORS
- Validate credentials in
onBeforeConnectand throw an error to reject unauthorized connections. - Identify users in actions by
c.conn.idand the identity verified when they connected, rather than trusting action parameters. - For cross-origin access, validate the request origin in
onBeforeConnect.
Authentication Documentation · CORS Documentation
Versions & Upgrades
Deploy new code by rolling your nodes. When a node stops, its actors sleep and wake on a running node with their state intact, so keep each deploy compatible with the state the previous one wrote. See Versions & Upgrades.
Anti-Patterns
Never build a “god” actor
Do not put all your logic in a single actor. A god actor serializes every operation through one bottleneck, kills parallelism, and makes the entire system fail as a unit. Split into focused actors per entity.
Never create an actor per request
Actors are long-lived and maintain state across requests. Creating a new actor for every incoming request throws away the core benefit of the model and wastes resources on actor creation and teardown. Use actors for persistent entities and regular functions for stateless work.