Skip to main content
Deprecated

Deprecated State

Migrate actors off the deprecated c.state and c.conn.state APIs to SQLite and c.vars.

c.state and c.conn.state were deprecated in 3.0 and will be removed in a future version. They still work today, so you can migrate at your own pace.

What is deprecated

Earlier versions of Rivet Actors stored durable data in two places that Rivet saved for you:

  • Actor state (c.state): a single serializable value defined with state or createState. Rivet wrote it to storage on a throttle and loaded it again on every start.
  • Connection state (c.conn.state): a value per connection defined with connState or createConnState.

The related options onStateChange, c.saveState(), stateSaveInterval and createConnStateTimeout are deprecated with them.

The Rust SDK no longer has actor state or connection state APIs. Store durable data with ctx.sql() and in-memory data in fields on your actor struct.

import { actor } from "rivetkit";

// An actor that still uses the deprecated `state` and `connState` options
const counter = actor({
  state: { count: 0 },
  connState: { joinedAt: 0 },

  onConnect: (c, conn) => {
    conn.state.joinedAt = Date.now();
  },

  actions: {
    increment: (c) => {
      c.state.count += 1;
      return c.state.count;
    },
  },
});

Use these instead:

  • Durable data goes in SQLite (c.db).
  • In-memory data goes in c.vars. It is rebuilt on every start and lost when the actor sleeps or crashes.
  • Connection data is tracked from onConnect and onDisconnect. See Connection state.

Migrate actor state to SQLite

Move existing data with a SQLite migration. It reads the old state once, writes it into your own tables, and from then on the actor only uses SQLite.

  1. Remove state or createState from the actor definition.
  2. Add a db with an onMigrate that creates your tables and copies the old state into them.
  3. Read and write the new tables from your actions. See Update data in SQL.

onMigrate runs on every start inside a transaction, so make the copy idempotent. The example skips it once the new row exists. The decoder needs the cbor-x package:

npm install cbor-x

Where the old state lives

Actor state is stored in the actor’s SQLite database in a table managed by Rivet. This table and its data will never be dropped, even after c.state is removed, so you can migrate out of it at any point:

CREATE TABLE _rivet_actor_state (
    id    INTEGER PRIMARY KEY CHECK (id = 1),
    state BLOB NOT NULL
) STRICT
  • There is at most one row, with id = 1. An actor that never had state may have no row or an empty blob.
  • state is the value of c.state encoded as CBOR. BigInt, Uint8Array, ArrayBuffer, Set and undefined are stored as two-element arrays such as ["$BigInt", "42"]. decodeLegacyState above turns them back into their original types. This tag encoding applies to TypeScript actors.
  • Only read from _rivet_* tables. Do not write to or drop them; Rivet manages them.

Update data in SQL

c.state changes ran synchronously, so two actions never interleaved in the middle of one. Every SQLite query is awaited and actions run concurrently, so an action can pause between reading a value and writing it back while another action changes it. Copying data into c.vars, changing the copy and writing it back has the same problem and loses updates.

Make each change one statement that computes the new value in SQL and returns it with RETURNING. Put the check in the WHERE clause so the check and the change cannot be split, and run statements that must commit together in c.db.transaction:

import { actor, UserError } from "rivetkit";
import { db } from "rivetkit/db";

const wallet = actor({
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
        CREATE TABLE IF NOT EXISTS wallet (
          id INTEGER PRIMARY KEY CHECK (id = 1),
          balance INTEGER NOT NULL
        );
      `);
			await db.execute(`
        CREATE TABLE IF NOT EXISTS ledger (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          amount INTEGER NOT NULL,
          created_at INTEGER NOT NULL
        );
      `);
			await db.execute(
				"INSERT OR IGNORE INTO wallet (id, balance) VALUES (1, 0)",
			);
		},
	}),

	actions: {
		balance: async (c) => {
			const [{ balance }] = await c.db.execute<{ balance: number }>(
				"SELECT balance FROM wallet WHERE id = 1",
			);
			return balance;
		},

		// The check and the change run in one statement, so two withdrawals
		// can never both pass the check
		withdraw: async (c, amount: number) => {
			const rows = await c.db.execute<{ balance: number }>(
				"UPDATE wallet SET balance = balance - ? WHERE id = 1 AND balance >= ? RETURNING balance",
				amount,
				amount,
			);
			if (rows.length === 0) {
				throw new UserError("Insufficient balance", {
					code: "insufficient_balance",
				});
			}
			return rows[0].balance;
		},

		// Statements that must commit together run in one transaction
		deposit: async (c, amount: number) => {
			return await c.db.transaction(
				async (tx) => {
					await tx.execute(
						"INSERT INTO ledger (amount, created_at) VALUES (?, ?)",
						amount,
						Date.now(),
					);
					const [{ balance }] = await tx.execute<{ balance: number }>(
						"UPDATE wallet SET balance = balance + ? WHERE id = 1 RETURNING balance",
						amount,
					);
					return balance;
				},
				{ name: "deposit" },
			);
		},
	},
});

Queries run in the actor’s own process, so reading from SQLite in each action is fast. Unlike c.state, a write is durable as soon as c.db.execute resolves.

Realtime loops

An actor that changes data on every tick, such as a game loop or a physics world, keeps the live simulation in c.vars and saves a snapshot to SQLite on an interval. c.state did this for you: it was saved about once per second by default (stateSaveInterval), so a crash lost up to a second of progress.

  1. Load the last snapshot in createVars.
  2. Save a snapshot from the loop once the interval has passed, and once more when the loop exits.
  3. Write each snapshot in one c.db.transaction so a crash never leaves half of one.
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 });
	});
}

The interval bounds how much progress a crash can lose. Data tied to a live connection, such as a player’s current input, stays out of the snapshot. The loop starts from onWake; see run for that pattern.

Connection state

Replace connState and createConnState with lifecycle hooks that key data by conn.id. Validate params in onBeforeConnect as before.

Regular connections

A regular connection ends when the actor sleeps, so its data only needs to live in memory. Add it to c.vars in onConnect and remove it in onDisconnect:

import { actor } from "rivetkit";

interface Presence {
  name: string;
  joinedAt: number;
}

const room = actor({
  // Live connections are tracked in memory, keyed by connection ID
  createVars: () => ({ presence: new Map<string, Presence>() }),

  onConnect: (c, conn) => {
    const { name } = conn.params as { name: string };
    c.vars.presence.set(conn.id, { name, joinedAt: Date.now() });
  },

  onDisconnect: (c, conn) => {
    c.vars.presence.delete(conn.id);
  },

  actions: {
    whoAmI: (c) => c.vars.presence.get(c.conn.id),
    online: (c) => [...c.vars.presence.values()],
  },
});

Hibernatable WebSockets

A hibernatable WebSocket stays open while the actor sleeps, and c.vars is rebuilt when it wakes. Store data for these connections in SQLite so it survives, and delete it in onDisconnect. A connection that closes while the actor is asleep may not trigger onDisconnect, so also delete rows whose conn_id is no longer in c.conns when the actor wakes:

import { actor } from "rivetkit";
import { db } from "rivetkit/db";

const room = actor({
  db: db({
    onMigrate: async (db) => {
      await db.execute(`
        CREATE TABLE IF NOT EXISTS presence (
          conn_id TEXT PRIMARY KEY,
          name TEXT NOT NULL,
          joined_at INTEGER NOT NULL
        );
      `);
    },
  }),

  // Hibernatable connections outlive the actor's memory, so store their data in SQLite
  onConnect: async (c, conn) => {
    const { name } = conn.params as { name: string };
    await c.db.execute(
      "INSERT OR REPLACE INTO presence (conn_id, name, joined_at) VALUES (?, ?, ?)",
      conn.id,
      name,
      Date.now(),
    );
  },

  onDisconnect: async (c, conn) => {
    await c.db.execute("DELETE FROM presence WHERE conn_id = ?", conn.id);
  },

  // A connection that closed while the actor slept may never reach
  // `onDisconnect`, so drop rows for connections that are no longer open
  onWake: async (c) => {
    const rows = (await c.db.execute("SELECT conn_id FROM presence")) as {
      conn_id: string;
    }[];
    for (const row of rows) {
      if (!c.conns.has(row.conn_id)) {
        await c.db.execute("DELETE FROM presence WHERE conn_id = ?", row.conn_id);
      }
    }
  },

  actions: {
    whoAmI: async (c) => {
      const rows = (await c.db.execute(
        "SELECT name, joined_at FROM presence WHERE conn_id = ?",
        c.conn.id,
      )) as { name: string; joined_at: number }[];
      return rows[0];
    },
  },
});

Hibernatable connection state is stored in the Rivet-managed _rivet_conn_state table, with conn_id TEXT as the key and the CBOR-encoded state BLOB. Migrate it in onMigrate with the same decoder if a connection must keep its data across the upgrade.

Edit this page Last updated November 10, 2026