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.

8 minute lesson

~~~

Modern Durable Objects expose public class methods through typed RPC. A Worker gets a typed stub and calls a method such as addMessage directly, like calling 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 deliberate trap in addMessage: the SQL insert and the this.messageCount property look equally persistent. They are not. Store critical records in the object’s SQLite database, because class properties disappear when the instance is evicted or restarted — and eviction is normal, not a failure. Idle objects leave memory routinely.

The honest roles: SQLite holds anything you can’t lose, and instance properties hold only rebuildable state — a cached computation, a lookup table you can 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, and return plain serializable 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 — the stub being typed doesn’t make the caller trustworthy — and return plain rows and values rather than clever objects. A narrow surface of a few explicit methods is also your permission boundary: every public method is something any code holding a stub can invoke.

Add addMessage and listMessages, restart the local runtime, and prove messages survive while an in-memory counter resets.

Lesson completed

Take this course offline

Get every free book and course as PDF and EPUB files.

Get the download library →