USERDO: PER-USER DURABLE OBJECTS

One Durable Object per user .. owned state, live updates, zero ops drama.

Technical Guide 2025.09.15

I got tired of bolt-on user state: a database row here, a Redis key there, a webhook you hope fires in order. UserDO is my answer .. give each user their own Durable Object and stop thinking about it.

The Quick Version

Every user gets a Durable Object keyed to their sub claim. Reads and writes go through that single instance, so you get ordering for free .. no race conditions, no "last write wins" surprises. SSE streams updates back to the browser the moment something changes.

The pattern clicked for me when I was building settings pages, presence indicators, and notification inboxes. All of those want the same thing: per-user state that's consistent and reactive. This is the boring, obvious way to do it on Cloudflare.

Architecture

Client ↔ Worker → UserDO(name = user.sub)
           ↓             ↓
         JWT auth       SSE updates

Infrastructure

Drop the package in. Alchemy handles the wiring.

# Install
bun install userdo

Your Durable Object

Extend UserDO and add whatever tables and methods your app needs. The base class handles auth, SSE, and the SQLite lifecycle .. you just define the schema that's actually yours.

// 1) Extend UserDO (your data + logic)
import { UserDO, type Env } from "userdo/server";
import { z } from "zod";

const PostSchema = z.object({ title: z.string(), content: z.string() });

export class BlogDO extends UserDO {
  posts: any;
  constructor(state: DurableObjectState, env: Env) {
    super(state, env);
    this.posts = this.table('posts', PostSchema, { userScoped: true });
  }
  async createPost(title: string, content: string) {
    return await this.posts.create({ title, content });
  }
  async getPosts() {
    return await this.posts.orderBy('createdAt', 'desc').get();
  }
}

Worker

Auth endpoints are already there. I didn't want to write JWT validation for the fifth time, so I baked it in .. just add your own routes on top.

// 2) Create Worker (auth built-in; add your endpoints)
import { createUserDOWorker, createWebSocketHandler, getUserDOFromContext } from 'userdo/server';
import type { BlogDO } from './blog-do';

const app = createUserDOWorker('BLOG_DO');
const wsHandler = createWebSocketHandler('BLOG_DO');

app.post('/api/posts', async (c) => {
  const user = c.get('user');
  if (!user) return c.json({ error: 'Unauthorized' }, 401);
  const { title, content } = await c.req.json();
  const blog = getUserDOFromContext(c, user.email, 'BLOG_DO') as unknown as BlogDO;
  const post = await blog.createPost(title, content);
  return c.json({ post });
});

export default {
  async fetch(request: Request, env: any, ctx: any) {
    if (request.headers.get('upgrade') === 'websocket') return wsHandler.fetch(request, env, ctx);
    return app.fetch(request, env, ctx);
  }
};

Client

The browser client manages tokens and opens the SSE connection. The weird thing is how little code this ends up being on the frontend .. auth and real-time just work, and you can focus on what the data actually means.

// 4) Browser client
import { UserDOClient } from 'userdo/client';

const client = new UserDOClient('/api');
await client.signup('user@example.com', 'password');
await client.login('user@example.com', 'password');
client.onChange('table:posts', (evt) => console.log('post changed', evt));

Wrangler Config

Enable SQLite storage on the DO via a migration. One migration, one flag .. that's genuinely all the database setup there is.

{
  "main": "src/index.ts",
  "compatibility_flags": ["nodejs_compat"],
  "vars": { "JWT_SECRET": "your-jwt-secret-here" },
  "durable_objects": {
    "bindings": [ { "name": "BLOG_DO", "class_name": "BlogDO" } ]
  },
  "migrations": [ { "tag": "v1", "new_sqlite_classes": ["BlogDO"] } ]
}

Built-In Endpoints

These ship out of the box. I use them in nearly every project that has a logged-in user.

Built-in endpoints:
- POST /api/signup
- POST /api/login
- POST /api/logout
- GET  /api/me
- GET  /api/ws (WebSocket)
- GET  /data
- POST /data
- Organizations: /api/organizations...

Organizations

This means you can also model orgs .. scoped tables, membership lists, the whole thing .. without standing up a separate service. It lives in the same DO graph, just namespaced differently.

// Organization-scoped example
import { UserDO, type Env } from 'userdo/server';
import { z } from 'zod';

const Project = z.object({ name: z.string() });
const Task = z.object({ title: z.string() });

export class TeamDO extends UserDO {
  projects: any; tasks: any;
  constructor(state: DurableObjectState, env: Env) {
    super(state, env);
    this.projects = this.table('projects', Project, { organizationScoped: true });
    this.tasks = this.table('tasks', Task, { organizationScoped: true });
  }
}