Guide · TypeScript and Node.js

RivetKit actors and Solid Objects: chat rooms with ordered state on PostgreSQL in Node.js

RivetKit actors need a Rivet control plane. Compare them with Solid Objects for chat rooms with ordered state in your own PostgreSQL database.

RivetKit is the TypeScript SDK for Rivet Actors: long-lived processes with durable state, queues, realtime connections, and scheduling. A Rivet control plane schedules the actors and stores their state. Solid Objects (the solid-objects npm package) gives each key an actor with an ordered mailbox, durable state, and reminders, in the SQLite, PostgreSQL, or MySQL database that the app already uses. It has no control plane. Choose RivetKit when you want built-in WebSocket connections, many actors that keep state in memory, and a platform that runs them for you. Choose Solid Objects when each change must commit to your own SQL database with its follow-up work, in order, without a new service to operate.

Solid Objects is an independent project. It is not affiliated with Rivet.

The short answer

Question RivetKit Solid Objects
What has state An actor and its key An actor class and an actor ID
One at a time "By default, actions run in parallel. If you need advanced control over concurrency, use queues" One turn at a time for each actor
When the state is saved "Rapid mutations are batched into a single write on a throttle (stateSaveInterval, default 1 second)… saves aren't tied to action or handler boundaries" Each turn commits the state, effects, and reminders in one SQL transaction
A failed queue message "If processing fails before message.complete(), the message is not redelivered" The message runs again. It holds back later messages until it succeeds or becomes a dead letter
Later work c.schedule.after and c.schedule.at. "Schedules survive actor sleep, restarts, upgrades, and crashes" this.schedule({ at, key }).operation(input)
Realtime Events over WebSockets with c.broadcast Committed broadcasts. Your app owns the WebSocket transport
What you run Your workers and a Rivet control plane. Rivet Cloud or BYOC can run it for you. Self-hosted, it is a Rust binary with RocksDB, PostgreSQL, or FoundationDB storage, plus NATS for more than one node Your Node.js process and your database
PostgreSQL A control plane storage backend, "production-ready for light-to-moderate workloads, up to roughly 1,000 concurrent actors" The application database, with your own tables beside it
Node.js Node.js 22 or newer Node.js 24.4 or newer
Licence Apache-2.0. The FoundationDB storage backend "requires an enterprise license" MIT. The optional Solid Objects Pro package is commercial

The RivetKit facts come from the Rivet documentation (checked October 15, 2026).

Choose RivetKit when

Choose Solid Objects when

The same terms in each

RivetKit Solid Objects
actor({ ... }) and its key An Actor class and an actor ID
An action An actor method (an operation)
A queue and a run loop The actor mailbox
c.state Actor fields
c.schedule.after schedule({ at, key }) and an operation
c.broadcast observables() and the realtime layer
client.chatRoom.getOrCreate([key]) runtime.ref(ChatRoom, id)

A chat room in each

The task: chat rooms in a Node.js TypeScript server with PostgreSQL. Several Node.js processes run. Messages in one room must get a strict order. Room state (members, unread counts, pinned message) must survive restarts. A room archives itself after 7 days with no activity. Rooms are independent of each other.

With RivetKit

This code follows the Rivet documentation. It was not compiled or run. The docs show c.saveState in actions but not inside run, and they do not give the type of the schedule ID.

import { actor, event, queue, setup } from "rivetkit"

const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000
type Message = { seq: number; from: string; text: string; at: number }

export const chatRoom = actor({
  createState: () => ({
    members: [] as string[],
    unread: {} as Record<string, number>,
    pinned: null as Message | null,
    recent: [] as Message[],
    seq: 0,
    lastActivityAt: Date.now(),
    archiveId: null as string | null,
    archived: false,
  }),
  queues: { post: queue<{ from: string; text: string }>() },
  events: { newMessage: event<Message>(), archived: event<{ at: number }>() },
  run: async (c) => {
    for await (const m of c.queue.iter()) {
      const msg: Message = { seq: ++c.state.seq, ...m.body, at: Date.now() }
      c.state.recent = [...c.state.recent, msg].slice(-100)
      for (const user of c.state.members.filter((u) => u !== msg.from)) {
        c.state.unread[user] = (c.state.unread[user] ?? 0) + 1
      }
      c.state.lastActivityAt = msg.at
      await c.saveState({ immediate: true })
      c.broadcast("newMessage", msg)
    }
  },
  actions: {
    join: (c, user: string) => {
      c.state.members = [...new Set([...c.state.members, user])]
    },
    pin: (c, seq: number) => {
      c.state.pinned = c.state.recent.find((m) => m.seq === seq) ?? null
    },
    sendMessage: async (c, from: string, text: string) => {
      await c.queue.send("post", { from, text })
      if (c.state.archiveId) await c.schedule.cancel(c.state.archiveId)
      c.state.archiveId = await c.schedule.after(SEVEN_DAYS, "archive")
    },
    archive: (c) => {
      if (Date.now() - c.state.lastActivityAt < SEVEN_DAYS) return
      c.state.archived = true
      c.broadcast("archived", { at: Date.now() })
    },
  },
})

export const registry = setup({ use: { chatRoom } })
registry.start()

What this code gets from RivetKit:

What to know:

With Solid Objects

There is one ChatRoom actor for each room ID.

import { Actor } from "solid-objects"

export const ARCHIVE_AFTER_MILLISECONDS = 7 * 24 * 60 * 60 * 1000

const KEPT_MESSAGES = 200

export type ChatMessage = { sequence: number; authorId: string; text: string }

export class ChatRoom extends Actor {
  static override readonly actorType = "ChatRoom"

  members: string[] = []
  messages: ChatMessage[] = []
  nextSequence = 1
  lastRead: Record<string, number> = {}
  pinned: ChatMessage | null = null
  activity = 0
  archived = false

  get unreadCounts(): Record<string, number> {
    const latest = this.nextSequence - 1
    return Object.fromEntries(
      this.members.map((memberId) => [memberId, latest - (this.lastRead[memberId] ?? 0)]),
    )
  }

  join({ memberId }: { memberId: string }): void {
    this.#requireOpen()
    if (this.members.includes(memberId)) return
    this.members = [...this.members, memberId]
    this.lastRead = { ...this.lastRead, [memberId]: this.nextSequence - 1 }
    this.#armArchive()
  }

  post({ authorId, text }: { authorId: string; text: string }): { sequence: number } {
    this.#requireMember(authorId)
    const sequence = this.nextSequence
    this.nextSequence += 1
    this.messages = [...this.messages, { sequence, authorId, text }].slice(-KEPT_MESSAGES)
    this.lastRead = { ...this.lastRead, [authorId]: sequence }
    this.#armArchive()
    return { sequence }
  }

  markRead({ memberId, sequence }: { memberId: string; sequence: number }): void {
    this.#requireMember(memberId)
    const current = this.lastRead[memberId] ?? 0
    this.lastRead = {
      ...this.lastRead,
      [memberId]: Math.min(Math.max(current, sequence), this.nextSequence - 1),
    }
  }

  pin({ memberId, sequence }: { memberId: string; sequence: number }): void {
    this.#requireMember(memberId)
    const message = this.messages.find((candidate) => candidate.sequence === sequence)
    if (!message) this.reject("unknown_message", { message: "That message is not in the room" })
    this.pinned = message
    this.#armArchive()
  }

  archiveIfIdle({ activity }: { activity: number }): void {
    if (activity !== this.activity) return
    this.archived = true
  }

  #requireMember(memberId: string): void {
    this.#requireOpen()
    if (!this.members.includes(memberId)) {
      this.reject("not_a_member", { message: "Join the room first" })
    }
  }

  #requireOpen(): void {
    if (this.archived) this.reject("archived", { message: "The room is archived" })
  }

  #armArchive(): void {
    this.activity += 1
    this.schedule({ at: new Date(Date.now() + ARCHIVE_AFTER_MILLISECONDS) }).archiveIfIdle({
      activity: this.activity,
    })
  }
}

The runtime policy lets a member act only in their own name. See the runtime file. Its query policy only checks that the caller signed in. A production policy also checks room membership from your own data.

How it works:

What the tests prove

The tests show these results:

Questions that teams ask

Is Solid Objects mature enough? Here are the facts:

Why Node.js 24.4 or newer? The SQLite adapter reads integer columns as BigInt. Node.js 24.4.0 is the first release that accepts readBigInts on the built-in SQLite module. See support.

Is delivery exactly once? No. Delivery is at least once. A turn can run again after a crash. Use the effect ID as the idempotency key for each external call. See correctness.

What if one room is very busy? One actor runs one turn at a time. A busy room handles its messages in sequence. Different rooms run at the same time.

Does it need another process? No new service. A process of your app calls runtime.run(signal) to run reminders and effects. While no process runs, pending work stays in SQL.

Sources

Checked October 15, 2026:

More information

Generated from docs/compare/rivetkit.md in cardmagic/solid-objects-js, v1.0.2, commit 0a26ebb. For Ruby on Rails, read Virtual actors in Ruby on Rails.