limenote
Blog · Engineering

One Durable Object per user: a sync backend with no database server

Limenote's backend is a Cloudflare Worker, one D1 database, one R2 bucket, and one SQLite-backed Durable Object per person. How the pieces fit, why sync is a five-second heartbeat instead of a WebSocket, and when you shouldn't copy this.

The limenote team

6 min read

Limenote's backend has no Postgres, no Redis and no WebSocket server. It runs on four Cloudflare pieces: a Worker in front, one D1 database that knows who owns what, one R2 bucket for files, and one SQLite-backed Durable Object per person, which we call a room.

We've run it in production since launch. This post covers how the pieces fit, the few decisions that made it work, and the places where the design costs us. If you're building something where each person's data is mostly their own (notes, journals, personal CRMs, local-first-ish apps with a server), this shape is worth a look.

Clients talk to a Worker, which looks up the user's room in D1 and forwards the request to that user's Durable Object; files go straight to R2
The whole backend. D1 is a directory, the room holds the data, R2 holds the bytes.

Finding the room

Every request carries a bearer token. The Worker hashes it with SHA-256, looks the hash up in D1's sessions table, and gets back a user with a room column. For a new account, the room name is simply the user id.

From there it's one call into the Durable Object namespace:

export function roomFor(rooms: RoomNamespace, room: string): RoomApi {
  return rooms.get(rooms.idFromName(room));
}

/** Sends a request to the user's room, overwriting the client's room headers. */
export function forwardToRoom(rooms: RoomNamespace, ref: RoomRef, req: Request) {
  const headers = new Headers(req.headers);
  headers.set('x-limenote-room', ref.room);
  headers.set('x-limenote-user', ref.userId);
  // …plus the storage quota for this user's plan
  return roomFor(rooms, ref.room).fetch(new Request(req, { headers }));
}

The room trusts those headers and nothing else. A request that reaches a room without them gets a 400, and the Worker always overwrites whatever the client sent. That one rule is the whole permission model: if the Worker sent you a request, it's for your room's owner.

Keeping the room name in a column instead of deriving it from the user id turned out to be useful, too. An admin can point a user at a different room without moving any data.

What lives where

Store Holds
D1 (one database) users, sessions, identities: who owns which room. Never a document.
The room's SQLite (one per user) workspaces, documents, document_units, stored_files, AI settings and sealed keys, AI conversations, the AI usage ledger, PDF uploads in progress
R2 (one bucket) Every room's files under rooms/<room>/: PDFs by asset id, images by content hash, so each one is stored once

The room reports its storage figures back to D1 after changes. That keeps cross-user views, like an admin list of accounts and their usage, possible without opening every room.

File bytes skip the room

Our first version streamed PDFs through the Durable Object. That was a mistake. A comment in our file routes records why:

A Durable Object streamed a 33 MB PDF out at about 60 KB/s, while R2 gives it in seconds.

So the room now decides and the Worker moves bytes. The room answers "is this your file, and which upload part is due next?" over RPC, and the Worker streams straight between the client and R2. Large PDFs go up as an R2 multipart upload in 8 MiB parts, written by the Worker, and the room only records which parts have arrived. If you take one thing from this post: keep big payloads out of your Durable Objects.

Sync without a WebSocket

This is the part people ask about. There's no live connection anywhere. Instead, each document that's open on screen reports in every five seconds:

PUT /api/workspaces/:ws/documents/:id/presence/:device
→ { devices: 2, updatedAt: "2026-11-06T05:58:41.112Z" }

The answer says how many devices have this document open right now, and when it last changed. A client fetches the document again only when both are true: another device is on it, and updatedAt is newer than the copy on screen. With one device open, the heartbeat costs one tiny request every five seconds and nothing else.

We measured it on a local server with two browser windows on the same whiteboard. Window A drew a circle, and its debounced save went out about 1.8 s in. Window B's next heartbeat, at 5.9 s, came back with a newer updatedAt. B fetched the document and showed the circle at 6.2 s.

Two browser windows on the same whiteboard: a circle drawn in window A appears in window B 6.2 seconds later
Two windows, one account, a local server. Real captures: the right-hand window was never reloaded.

Why not a WebSocket? Because the problem is one person on two or three screens, not twenty people in one document. Up to five seconds of delay is fine when you're the only one writing. And a heartbeat does two jobs: it is the presence signal. The presence registry lives in the room's memory with a 15-second expiry and is never written to disk. After a restart, every open device simply reports in again.

Saving, and what happens when two devices disagree

Edits are saved as a debounced PUT (450 ms after the last change) carrying { upserts, deleteIds } plus document-level fields such as the camera and page layout. The server does an unconditional upsert per unit:

INSERT INTO document_units (id, document_id, …, data, updated_at)
VALUES (?, ?, …, ?, ?)
ON CONFLICT(id) DO UPDATE SET
  data = excluded.data,
  updated_at = excluded.updated_at;

So conflicts resolve as last writer wins, per whole unit. A unit is a stroke, a sticky note or a shape. Two devices moving the same sticky note at the same moment will lose one move. For a single person, that almost never happens. For real collaboration it would be unacceptable, and you'd want CRDTs.

The client side of a merge matters more than the server side:

  • Take the remote units, but keep the local version of anything with a pending save, and don't bring back anything deleted locally that's still pending.
  • Never merge in the middle of a gesture. If the pen is down, or text is being edited, wait.

Offline, per client

Decide early where unsent edits live on each platform. We made different choices:

  • iOS writes each open document to disk along with its pending changes. A failed save is retried with backoff (5, 10, 20, 40, then every 60 seconds), and a document reopened from disk resends whatever was still pending.
  • The web app keeps unsent edits in memory and flushes them when the tab is hidden or closed. Its IndexedDB copy is a cache of what the server already has, not an outbox.

Upkeep with alarms

A Durable Object can set an alarm, which is all the cron you need. A minute after any write, the room wakes up and does three things:

  1. Sweeps unused images in two passes. An image no longer used anywhere is marked first, and deleted only if it's still unused 24 hours later. The grace period exists for undo: delete a sticky note with an image, press undo an hour later, and the image is still there.
  2. Aborts PDF uploads left unfinished for 24 hours.
  3. Reports usage back to D1.

If anything is still waiting, it re-arms itself for twelve hours later.

Moving and deleting are easy

Because everything a person owns is in one room plus one R2 prefix, the hard operations become simple:

  • Merging a guest notebook into an account after sign-in exports the guest room one workspace at a time and imports it into the account's room. Row ids are kept and inserted with INSERT OR IGNORE, so an interrupted move can just run again. Files move first.
  • Deleting an account removes the R2 prefix, deletes the Durable Object's storage, then deletes the D1 rows. There's no hunting for a user's rows across thirty tables.

What it costs

  • No cross-user queries. Anything you want to see across users (usage, counts) has to be pushed out of the rooms on purpose. We copy a few numbers to D1.
  • Migrations happen per room, when a room opens. Every room runs CREATE TABLE IF NOT EXISTS and copies legacy tables forward when it starts. That's fine, but you'll carry old shapes in your code for a long time.
  • One room is one place. A single very heavy user can't spill across machines. For notebooks that's a fine bet. For an analytics product it wouldn't be.
  • Admin tooling has to go through RPC. There's no psql session into everyone's data, which is a feature until you need it.

When not to copy this

Don't use this shape if people edit the same document at the same time, if you need search across users, or if one tenant can grow without bound. Do consider it if each person's data is mostly theirs, they use it from a few devices, and you'd rather never run a database server. For us, the whole backend is one Worker deploy.

Questions people ask

Why one Durable Object per user instead of one shared database?

A notebook belongs to one person and is edited from that person's devices. Giving each person their own SQLite in a Durable Object puts all of their data in one place, removes noisy neighbours, makes deletion a single wipe, and keeps every query small. The price is that you can no longer query across users.

Does this need WebSockets for sync?

Not for one person on several screens. Each open document reports in every five seconds and gets back how many devices have it open and when it last changed. A device only fetches the document again when another device is there and the document is newer. In our test, a stroke drawn in one window showed up in the other about six seconds later.

When is this design the wrong choice?

When several people edit the same document at the same time, when you need search or analytics across users, or when one tenant's data can grow without bound. Real-time collaboration needs operational transforms or CRDTs and a live connection; whole-unit last-writer-wins and a heartbeat aren't enough.

One notebook. Every screen.

Get Limenote on iPad and iPhone, or open it in your browser. Your pages are already waiting on the other side.

More from the blog