boris tane
blogtalksprojects

One Database Per User with Cloudflare Durable Objects and Drizzle ORM

I’m building a production-ready app entirely on the Cloudflare Developers Platform (Basebrain) and I want to share implementation details of the key patterns I use.

Today, I’m focusing on a specific pattern that’s surprisingly simple to implement: creating one isolated database per user using Durable Objects and Drizzle ORM.

Durable Objects are one of the most powerful primitives on the Cloudflare Developer Platform, but I have seen many people struggle to define them. I see them as mini servers without the ops.

Each object (server) is:

  • Addressable by a unique name/ID, like a key in a distributed system.
  • Run in a single location at any given time, so no race conditions.
  • Can store persistent state, backed by Durable Object Storage.
  • Can coordinate between multiple clients, like a chat room, game session, or rate limiter.

They’re great when you need strong consistency, low-latency coordination, or lightweight stateful logic.

Durable Objects provide a convenient SQLite API, and when combined with Drizzle ORM, give you a clean, type-safe way to create a database per user in your applications.

One Database Per User

Before looking at implementation, let’s understand why you might want one database per user:

  1. Scalability - Sharding a database into one instance per user addresses the limitations of storing and managing large datasets on a single database server
  2. True isolation - Each user’s data is completely isolated
  3. Simplified access control - No complex filtering or multi-tenancy code
  4. Performance - Durable Objects are distributed globally, and instanciated closest to the first request they receive. As such, each database will be instanciated closes to its users, significantly reducing end-to-end latency

In this example, we’ll build a simple notes API where each user gets their own SQLite database. A user can create, read, list, or delete notes. They will be able to interact exclusively with their own database. By definition, a user will not be able to access another user’s data.

The complete code for this tutorial is available on GitHub.

Throughout this example, we’ll assume you’re familiar with deploying Cloudflare Workers and we will focus on Durable Objects.

Project Overview

Let’s start with an overview of the project. We’ll be using:

Here’s the basic structure of the project:

1
.
2
├── bindings.ts # Bindings TypeScript Definition
3
├── package.json # Dependencies and scripts
4
├── src
5
│ ├── db # Database-related code
6
│ │ ├── index.ts # CRUD operations
7
│ │ ├── notes.ts # Schema definition
8
│ │ ├── schemas.ts # Schema exports
9
│ │ └── types.ts # TypeScript types
10
│ └── index.ts # Main Worker code and Durable Object
11
├── tsconfig.json # TypeScript configuration
12
└── wrangler.json # Cloudflare Workers configuration

Bootstrapping the Durable Object

Let’s start with bootstrapping our Durable Object code.

Each Durable Object is part of a Durable Object Namespace. A Durable Object Namespace is how you reference a Durable Object class from your Worker. It’s like a binding that lets you create or execute methods in the Durable Objects from your Worker. A Durable Object Namespace must be bound to your Worker before you can instanciate Durable Objects.

After creating a Node.js project with npm and configuring TypeScript, let’s create our wrangler.json file to configure our Worker and Durable Object Namespace.

wrangler.json
1
{
2
"name": "durable-objects-database-per-user",
3
"compatibility_date": "2024-11-12",
4
"workers_dev": true,
5
"upload_source_maps": true,
6
"observability": {
7
"enabled": true
8
},
9
"main": "./src/index.ts",
10
"migrations": [
11
{
12
"new_sqlite_classes": [
13
"DurableDatabase"
14
],
15
"tag": "v1"
16
}
17
],
18
"durable_objects": {
19
"bindings": [
20
{
21
"class_name": "DurableDatabase",
22
"name": "DurableDatabase"
23
}
24
]
25
}
26
}

Our Worker script name will be durable-objects-database-per-user, it will have a worker.dev URL, and observability enabled. Our Durable Object class will be DurableDatabase and we enabled the Durable Object SQLite API on it.

Next, let’s write the code for our Worker and our Durable Objects.

src/index.ts
1
import { DurableObject } from 'cloudflare:workers';
2
import { Hono } from 'hono'
3
import { Bindings } from '../bindings';
4
5
const app = new Hono<{ Bindings: Bindings }>();
6
7
app.get('/', async (c) => {
8
return c.json({ message: "Hello World!" })
9
});
10
11
export default {
12
async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise<Response> {
13
return app.fetch(request, env, ctx);
14
},
15
};
16
17
export class DurableDatabase extends DurableObject {
18
constructor(ctx: DurableObjectState, env: Bindings) {
19
super(ctx, env);
20
}
21
}

It’s a Hono API, and the DurableDatabase doesn’t do anything yet. We will come back to implement its methods.

We import Bindings from our bindings definition file. The Bindings type enables us to have type-safety and auto-completion when interacting with resources bound to the Worker.

src/bindings.ts
1
import type { DurableDatabase } from "./src/index.ts";
2
3
export type Bindings = {
4
DurableDatabase: DurableObjectNamespace<DurableDatabase>;
5
};

We are pointing out to TypeScript that there’s a Durable Object Namespace called DurableDatabase implementing the Durable Object class DurableDatabase bound to our Worker.

Database Schema

Now let’s define our database schema. Let’s keep it simple: a note has an ID, a text content, and created and updated timestamps.

We use the sqliteTable function from Drizzle ORM to create schema for our table:

src/db/notes.ts
1
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
3
export const notes = sqliteTable(
4
"notes",
5
{
6
id: text("id")
7
.notNull()
8
.primaryKey()
9
.$defaultFn(() => `note_${randomString()}`),
10
11
text: text("text").notNull(),
12
13
created: integer("created", { mode: "timestamp_ms" })
14
.$defaultFn(() => new Date())
15
.notNull(),
16
updated: integer("updated", { mode: "timestamp_ms" })
17
.$onUpdate(() => new Date())
18
.notNull(),
19
},
20
);
21
22
// Helper to generate random IDs for our notes
23
function randomString(length = 16): string {
24
const chars = "abcdefghijklmnopqrstuvwxyz";
25
const resultArray = new Array(length);
26
27
for (let i = 0; i < length; i++) {
28
const randomIndex = Math.floor(Math.random() * chars.length);
29
resultArray[i] = chars[randomIndex];
30
}
31
32
return resultArray.join("");
33
}

Alongside our schemas, we need to define the TypeScript types of notes saved in the database. We can infer those from the schema thanks to Drizzle $infer properties.

src/db/types.ts
1
import type * as schema from "./schemas";
2
import { notes } from "./notes";
3
4
export type Note = typeof notes.$inferSelect;
5
export type InsertNote = typeof notes.$inferInsert;

We also create a type for our Database, such that we can easily use it when making database operations.

src/db/types.ts
1
import type { DrizzleSqliteDODatabase } from "drizzle-orm/durable-sqlite";
2
import type * as schema from "./schemas";
3
import { notes } from "./notes";
4
5
export type DB = DrizzleSqliteDODatabase<typeof schema>;
6
7
export type Note = typeof notes.$inferSelect;
8
export type InsertNote = typeof notes.$inferInsert;

And we export all the schemas in a single file.

src/db/schemas.ts
1
export * from "./notes";

Database Operations

Now that we have a schema, let’s create the functions that will interact with our database. These will be the core operations our Durable Object will expose. Each use should have the ability to create a new note, delete a note by ID, get a specific note by ID, and list all notes.

src/db/index.ts
1
import { eq } from "drizzle-orm";
2
import { notes } from "./notes";
3
import { DB, InsertNote, Note } from "./types";
4
5
// Create a new note
6
export async function create(db: DB, note: InsertNote): Promise<Note> {
7
const [res] = await db
8
.insert(notes)
9
.values(note)
10
.onConflictDoUpdate({
11
target: [notes.id],
12
set: note,
13
})
14
.returning();
15
16
return res;
17
}
18
19
// Delete a note by ID
20
export async function del(db: DB, params: { id: string }): Promise<Note> {
21
const [note] = await db
22
.delete(notes)
23
.where(eq(notes.id, params.id))
24
.returning();
25
return note;
26
}
27
28
// Get a note by ID
29
export async function get(db: DB, params: { id: string }): Promise<Note | null> {
30
const [result] = await db
31
.select()
32
.from(notes)
33
.where(eq(notes.id, params.id));
34
if (!result) return null;
35
return result;
36
}
37
38
// List all notes
39
export async function list(db: DB): Promise<Note[]> {
40
const ns = await db
41
.select()
42
.from(notes)
43
return ns;
44
}

These operations are using fairly standard Drizzle’s query builder API functions, it gives us a clean, fluent interface for building SQL queries.

Implementing the Durable Object

Now let’s look at how we implement our Durable Object. This is where the magic happens, each Durable Object instance will be tied to a specific user and contain their own SQLite database.

src/index.ts
1
import { DurableObject } from 'cloudflare:workers';
2
import { Hono } from 'hono'
3
import { Bindings } from '../bindings';
4
import { drizzle } from 'drizzle-orm/durable-sqlite';
5
import * as schema from "./db/schemas";
6
import * as notes from "./db/index";
7
8
import { DB } from './db/types';
9
10
const app = new Hono<{ Bindings: Bindings }>();
11
12
app.get('/', async (c) => {
13
return c.json({ message: "Hello World!" })
14
});
15
16
export default {
17
async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise<Response> {
18
return app.fetch(request, env, ctx);
19
},
20
};
21
22
export class DurableDatabase extends DurableObject {
23
private db: DB;
24
25
constructor(ctx: DurableObjectState, env: Bindings) {
26
super(ctx, env);
27
// Initialize Drizzle with the Durable Object's storage
28
this.db = drizzle(ctx.storage, { schema, logger: true });
29
}
30
31
async notesCreate(note: Parameters<typeof notes.create>[1]): ReturnType<typeof notes.create> {
32
return await notes.create(this.db, note);
33
}
34
35
async notesGet(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.get> {
36
return await notes.get(this.db, params);
37
}
38
39
async notesList(): ReturnType<typeof notes.list> {
40
return await notes.list(this.db);
41
}
42
43
async notesDel(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.del> {
44
return await notes.del(this.db, params);
45
}
46
}

Let’s break down what’s happening here:

  1. In the constructor, we initialize Drizzle with ctx.storage, which is the Durable Object’s built-in storage that SQLite operates on. This gives us a DrizzleSqliteDODatabase which is unique per Durable Object. Everytime we do an operation using this object, we are interacting with a single SQLite instance.

  2. We create methods that map directly to our database operations. These methods will be called from our API routes.

The key insight here is that each instance of our DurableDatabase class is completely isolated from other instances. When we create a Durable Object for a user, they get their own private database.

Database Migrations

Database migration for Durable Object SQLite can be confusing, but the key thing to understand is that migrations must be run in the constructor of the Durable Object class.

Durable Object constructors run when a request is routed to an instance that isn’t currently active, basically on cold start. When you try to access a specific Durable Object from a Worker using its name/ID, if the Durable Object for that ID isn’t already in memory somewhere in Cloudflare’s edge network, a new instance spins up and the constructor is run once.

After that, the object stays warm for a while (usually minutes of inactivity) before being evicted from memory. If another request comes in later, the object will spin up again and the constructor is called again.

It then makes sense to run migrations in the Durable Object class constructor. When a new request is routed to a specific Durable Objects, the migration files since the last migration will be run in the constructor. This enables doing migrations on potentially millions of Durable Objects, “just-in-time”.

src/index.ts
1
import { DurableObject } from 'cloudflare:workers';
2
import { Hono } from 'hono'
3
import { Bindings } from '../bindings';
4
import { drizzle } from 'drizzle-orm/durable-sqlite';
5
import { migrate } from 'drizzle-orm/durable-sqlite/migrator';
6
import migrations from '../drizzle/migrations';
7
import * as schema from "./db/schemas";
8
import * as notes from "./db/index";
9
10
import { DB } from './db/types';
11
12
const app = new Hono<{ Bindings: Bindings }>();
13
14
app.get('/', async (c) => {
15
return c.json({ message: "Hello World!" })
16
});
17
18
export default {
19
async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise<Response> {
20
return app.fetch(request, env, ctx);
21
},
22
};
23
24
export class DurableDatabase extends DurableObject {
25
private db: DB;
26
27
constructor(ctx: DurableObjectState, env: Bindings) {
28
super(ctx, env);
29
// Initialize Drizzle with the Durable Object's storage
30
this.db = drizzle(ctx.storage, { schema, logger: true });
31
32
// Run migrations before accepting any requests
33
ctx.blockConcurrencyWhile(async () => {
34
await this._migrate();
35
});
36
}
37
38
async notesCreate(note: Parameters<typeof notes.create>[1]): ReturnType<typeof notes.create> {
39
return await notes.create(this.db, note);
40
}
41
42
async notesGet(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.get> {
43
return await notes.get(this.db, params);
44
}
45
46
async notesList(): ReturnType<typeof notes.list> {
47
return await notes.list(this.db);
48
}
49
50
async notesDel(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.del> {
51
return await notes.del(this.db, params);
52
}
53
54
private async _migrate() {
55
await migrate(this.db, migrations);
56
}
57
}

We use blockConcurrencyWhile to run migrations before accepting any requests. This ensures our database schema is up-to-date.

You’re asking yourself what is the migrations we import from ../drizzle/migrations. These are database migration files created by Drizzle Kit. Drizzle Kit is a CLI tool for managing SQL database migrations with Drizzle.

Let’s define our Drizzle config with the durable-sqlite driver.

drizzle.config.ts
1
import { defineConfig } from 'drizzle-kit';
2
3
export default defineConfig({
4
out: './drizzle',
5
schema: './src/db/schemas.ts',
6
dialect: 'sqlite',
7
driver: 'durable-sqlite',
8
});

To create the migration files, it’s necessary to run the Drizzle Kit generate command:

Terminal window
1
drizzle-kit generate

This command will create a new folder drizzle in our project structure, which will keep track of all our migrations. After every change to our database schema, we need to run the same command to generate migration files.

These migration files need to be uploaded alongside our Worker code, such that they can be run on Durable Object cold-starts. However, wrangler, the Cloudflare Developer Platform command-line interface, only uploads files it generates when bundling a project. It is necessary to explicitely require migration files to be uploaded alongside the Worker code.

wrangler.json
1
{
2
"name": "durable-objects-database-per-user",
3
"compatibility_date": "2024-11-12",
4
"workers_dev": true,
5
"upload_source_maps": true,
6
"observability": {
7
"enabled": true
8
},
9
"main": "./src/index.ts",
10
"migrations": [
11
{
12
"new_sqlite_classes": [
13
"DurableDatabase"
14
],
15
"tag": "v1"
16
}
17
],
18
"rules": [
19
{
20
"type": "Text",
21
"globs": [
22
"**/*.sql"
23
],
24
"fallthrough": true
25
}
26
],
27
"durable_objects": {
28
"bindings": [
29
{
30
"class_name": "DurableDatabase",
31
"name": "DurableDatabase"
32
}
33
]
34
}
35
}

The rules section tells Wrangler to include all .sql files in our deployment. Without this rule, our SQL migration files wouldn’t be included in the deployment, and our migrations would fail.

Setting Up API Routes

Now let’s set up our API routes to interact with our Durable Objects.

src/index.ts
1
import { DurableObject } from 'cloudflare:workers';
2
import { Hono } from 'hono'
3
import { Bindings } from '../bindings';
4
import { drizzle } from 'drizzle-orm/durable-sqlite';
5
import { migrate } from 'drizzle-orm/durable-sqlite/migrator';
6
import migrations from '../drizzle/migrations';
7
import * as schema from "./db/schemas";
8
import * as notes from "./db/index";
9
10
import { DB } from './db/types';
11
12
const app = new Hono<{ Bindings: Bindings }>();
13
14
app.get('/', async (c) => {
15
return c.json({ message: "Hello World!" })
16
});
17
18
export default {
19
async fetch(request: Request, env: Bindings, ctx: ExecutionContext): Promise<Response> {
20
return app.fetch(request, env, ctx);
21
},
22
};
23
24
function getDurableDatabaseStub(env: Bindings, userId: string) {
25
const doId = env.DurableDatabase.idFromName(userId);
26
return env.DurableDatabase.get(doId);
27
}
28
29
// Create a note for a user
30
app.post('/:userId', async (c) => {
31
const userId = c.req.param("userId");
32
const { text } = await c.req.json();
33
const stub = getDurableDatabaseStub(c.env, userId);
34
const note = await stub.notesCreate({ text });
35
return c.json({ note })
36
});
37
38
// List all notes for a user
39
app.get('/:userId', async (c) => {
40
const userId = c.req.param("userId");
41
const stub = getDurableDatabaseStub(c.env, userId);
42
const notes = await stub.notesList()
43
return c.json({ notes })
44
});
45
46
// Get a specific note for a user
47
app.get('/:userId/:noteId', async (c) => {
48
const userId = c.req.param("userId");
49
const noteId = c.req.param("noteId");
50
const stub = getDurableDatabaseStub(c.env, userId);
51
const note = await stub.notesGet({ id: noteId });
52
if (!note) {
53
return c.notFound();
54
}
55
return c.json({ note })
56
});
57
58
// Delete a note for a user
59
app.delete('/:userId/:noteId', async (c) => {
60
const userId = c.req.param("userId");
61
const noteId = c.req.param("noteId");
62
const stub = getDurableDatabaseStub(c.env, userId);
63
const note = await stub.notesDel({ id: noteId });
64
return c.json({ note })
65
});
66
67
export class DurableDatabase extends DurableObject {
68
private db: DB;
69
70
constructor(ctx: DurableObjectState, env: Bindings) {
71
super(ctx, env);
72
// Initialize Drizzle with the Durable Object's storage
73
this.db = drizzle(ctx.storage, { schema, logger: true });
74
75
// Run migrations before accepting any requests
76
ctx.blockConcurrencyWhile(async () => {
77
await this._migrate();
78
});
79
}
80
81
async notesCreate(note: Parameters<typeof notes.create>[1]): ReturnType<typeof notes.create> {
82
return await notes.create(this.db, note);
83
}
84
85
async notesGet(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.get> {
86
return await notes.get(this.db, params);
87
}
88
89
async notesList(): ReturnType<typeof notes.list> {
90
return await notes.list(this.db);
91
}
92
93
async notesDel(params: Parameters<typeof notes.get>[1]): ReturnType<typeof notes.del> {
94
return await notes.del(this.db, params);
95
}
96
97
private async _migrate() {
98
await migrate(this.db, migrations);
99
}
100
}

The key pattern in each route is:

  • Extract the userId from the URL
  • Get a Durable Object ID using idFromName(userId)
  • Get a stub to the Durable Object using get(doId)
  • Call methods on the stub to interact with the user’s database

A durable object stub is the proxy we use to send requests to a specific Durable Object instance. We get the Durable Object ID from the userId, and we use it to instanciate a lightweight proxy object (the stub).

1
function getDurableDatabaseStub(env: Bindings, userId: string) {
2
const doId = env.DurableDatabase.idFromName(userId);
3
return env.DurableDatabase.get(doId);
4
}

With the stub, we can call methods on the Durable Object using JavaScript-native RPC. That’s how we’re able to call the notesCreate, notesGet, notesDel, and notesList methods in the Durable Object.

This is the core of our “one database per user” pattern. By using idFromName(userId), we’re creating a consistent mapping from user IDs to Durable Objects. Each user gets their own Durable Object, and thus their own database.

Testing It Out

Now let’s see how our “one database per user” pattern works in practice. After you have deployed your Worker using wrangler, you’ll get a URL for your Worker:

1
https://durable-objects-database-per-user.account-name.workers.dev

where account-name is the name of your account.

Here are some example API calls:

Creating a note for user “john”

Terminal window
1
curl -X POST https://durable-objects-database-per-user.account-name.workers.dev/john \
2
-H "Content-Type: application/json" \
3
-d '{"text":"Buy groceries"}'
4
5
# Response:
6
# {
7
# "note": {
8
# "id": "note_jxqegmbonzvdstpy",
9
# "text": "Buy groceries",
10
# "created": "2025-03-23T15:42:18.760Z",
11
# "updated": "2025-03-23T15:42:18.760Z"
12
# }
13
# }

Listing all notes for “john”

Terminal window
1
curl https://durable-objects-database-per-user.account-name.workers.dev/john
2
3
# Response:
4
# {
5
# "notes": [
6
# {
7
# "id": "note_jxqegmbonzvdstpy",
8
# "text": "Buy groceries",
9
# "created": "2025-03-23T15:42:18.760Z",
10
# "updated": "2025-03-23T15:42:18.760Z"
11
# }
12
# ]
13
# }

Creating a note for user “bob”

Terminal window
1
curl -X POST https://durable-objects-database-per-user.account-name.workers.dev/bob \
2
-H "Content-Type: application/json" \
3
-d '{"text":"Finish the report"}'
4
5
# Response:
6
# {
7
# "note": {
8
# "id": "note_lnkcpfqyrtzbmags",
9
# "text": "Finish the report",
10
# "created": "2025-03-23T15:43:05.120Z",
11
# "updated": "2025-03-23T15:43:05.120Z"
12
# }
13
# }

Listing all notes for “bob”

Terminal window
1
curl https://durable-objects-database-per-user.account-name.workers.dev/bob
2
3
# Response:
4
# {
5
# "notes": [
6
# {
7
# "id": "note_lnkcpfqyrtzbmags",
8
# "text": "Finish the report",
9
# "created": "2025-03-23T15:43:05.120Z",
10
# "updated": "2025-03-23T15:43:05.120Z"
11
# }
12
# ]
13
# }

The key insight here is that “john” and “bob” have completely separate databases.

Limitations and Considerations

While this pattern is powerful, there are a few things to keep in mind:

  1. Storage Limits - Each Durable Object has a storage limit (currently 1GB), which could be limiting and push us to further sharding depending on the scale of our app
  2. Cross-User Operations - Queries across multiple users’ data, for example back-office queries or analytics, require additional work

On Basebrain, I’m going around these with further sharding and by using a different database for analytics, Workers Analytics Engine.

share on
twitter
mail
Scroll to top
Join 200+ developers getting updates on cloud computing, observability, serverless and more.