Skip to main content
Features

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.

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.

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:

Edit this page Last updated November 10, 2026