RPC and SQLite storage

Use typed RPC and durable storage

Expose narrow public methods, persist critical state first, and use memory only as a rebuildable cache.

Modern Durable Objects expose public class methods through typed RPC. A Worker gets a stub and calls a method like addMessage directly, as if it were a local object. No route parsing, no JSON envelope, and TypeScript checks the argument types across the boundary.

The object side is plain methods backed by SQLite:

import { DurableObject } from 'cloudflare:workers'

export class ProjectRoom extends DurableObject {
  async addMessage(author, body) {
    this.ctx.storage.sql.exec(
      'insert into messages (author, body, created_at) values (?, ?, ?)',
      author, body, Date.now()
    )
    this.messageCount = (this.messageCount ?? 0) + 1 // memory only!
  }

  async listMessages() {
    return this.ctx.storage.sql
      .exec('select author, body, created_at from messages order by id desc limit 50')
      .toArray()
  }
}

The Worker calls the methods through a stub:

const room = env.PROJECT_ROOM.get(env.PROJECT_ROOM.idFromName(roomId))
await room.addMessage('flavio', 'shipping today')
const messages = await room.listMessages()

Memory is a cache, storage is the truth

Notice the trap I left in addMessage. The SQL insert and the this.messageCount property look equally persistent. They are not.

Class properties disappear when the instance is evicted or restarted. And eviction is normal, not a failure. Idle objects leave memory all the time.

So the roles are clear. SQLite holds anything you can’t lose. Instance properties hold only state you can rebuild, like a cached computation or a lookup table you re-derive from storage in the constructor.

Prove it to yourself. Restart the local runtime mid-session:

> await room.addMessage('flavio', 'first')     // count: 1
# restart wrangler dev
> await room.listMessages()                    // message survives
> // this.messageCount is undefined again      // counter reset

The message came back. The in-memory counter did not. Any feature that depended on messageCount was silently broken, and only the restart revealed it.

Keep the RPC surface narrow

Keep public RPC inputs small, validated, and serializable. Return plain values. Arguments and return values cross an isolate boundary via structured clone, so functions, open streams, and class instances with behavior don’t travel.

Validate inputs inside the method. A typed stub doesn’t make the caller trustworthy. And remember that every public method is something any code holding a stub can call, so a narrow surface of a few explicit methods is also your permission boundary.

Try this: add addMessage and listMessages, restart the local runtime, and confirm messages survive while an in-memory counter resets.

Lesson completed