Skip to main content
Features

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.

Ephemeral

Live objects on c.vars like database connections, API clients, and event emitters, or data loaded from an external source. Never persisted.

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.

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:

Debugging

  • GET /inspector/state returns the actor’s current state and isStateEnabled.
  • PATCH /inspector/state lets you set state directly while debugging.
  • In non-dev mode, inspector endpoints require authorization.

API Reference