Cloudflare Durable Objects tutorial
By Flavio Copes
Learn Cloudflare Durable Objects step by step using TypeScript, RPC methods, named instances, and each object's embedded SQLite database.
Durable Objects are one of the most useful parts of Cloudflare Workers.
They are also difficult to understand from a list of APIs.
So let’s build one.
We will create a small counter. The counter is not the point of the tutorial. It is just small enough that we can focus on the ideas that make Durable Objects different:
- every object has a stable identity
- every object runs in one place at a time
- every object has private persistent storage
- requests for the same object can coordinate safely
- different objects can work independently
By the end, you will know how to create, call, test, and deploy a Durable Object.
More importantly, you will know when to use one.
Why Durable Objects exist
A normal Worker is stateless.
It receives a request, runs some code, and returns a response. Another request might run in another location on another instance.
This is perfect when requests are independent.
The model gets harder when several requests need to agree on shared state.
Imagine two requests incrementing the same counter. Both read 10. Both add one. Both write 11.
One increment disappears.
The same problem appears in real applications:
- two people reserve the last seat
- two players make a move at the same time
- several users edit the same document
- hundreds of clients send messages to one chat room
- multiple requests update one rate limit
A Durable Object gives that shared state one owner.
Cloudflare sends every request for the same object to the same logical instance. The object can keep temporary state in memory and durable state in its own SQLite database.
The useful mental model is:
A Durable Object is a small named server with private storage.
It is not one server for your complete application.
You normally create many objects: one per room, document, game, user, device, tenant, or other unit that needs coordination.
Create the project
You need a Cloudflare account and Node.js installed.
If Workers are new to you, my free Cloudflare Workers course explains the runtime, Wrangler, bindings, storage, testing, and deployment through a complete project.
Create a new project:
npm create cloudflare@latest -- durable-objects-tutorial
Choose these options:
Hello World exampleWorker + Durable ObjectsTypeScriptYesfor GitNofor the first deployment
Move into the project:
cd durable-objects-tutorial
We will work with two files:
durable-objects-tutorial/
├── src/
│ └── index.ts
└── wrangler.jsonc
The project contains a normal Worker and a Durable Object class.
The Worker receives public HTTP requests. It chooses an object and calls it.
The Durable Object owns the state.
Configure the Durable Object
Open wrangler.jsonc and replace it with this:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "durable-objects-tutorial",
"main": "src/index.ts",
"compatibility_date": "2026-08-13",
"durable_objects": {
"bindings": [
{
"name": "COUNTERS",
"class_name": "Counter"
}
]
},
"exports": {
"Counter": {
"type": "durable-object",
"storage": "sqlite"
}
}
}
There are two new parts here.
The durable_objects.bindings entry exposes a namespace called COUNTERS to our Worker.
The exports entry tells Cloudflare to provision the Counter class with SQLite storage.
Older tutorials use a migrations array with new_sqlite_classes. Cloudflare now calls that the legacy flow. New projects use exports.
Do not combine the two flows in one Worker.
Create your first Durable Object class
Open src/index.ts and start with this:
import { DurableObject } from 'cloudflare:workers'
interface Env {
COUNTERS: DurableObjectNamespace<Counter>
}
export class Counter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
}
}
A Durable Object is a class that extends DurableObject.
The ctx argument gives the object access to its state and storage. The env argument contains its bindings.
The Env interface describes the COUNTERS binding we added to wrangler.jsonc.
At this point the class does nothing. Let’s give it persistent state.
Create the SQLite table
Every new Durable Object gets its own embedded SQLite database.
Add the table setup to the constructor:
export class Counter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS counter (
id INTEGER PRIMARY KEY CHECK (id = 1),
value INTEGER NOT NULL
);
INSERT OR IGNORE INTO counter (id, value)
VALUES (1, 0);
`)
}
}
The table always contains one row.
We create it with an initial value of 0.
The constructor can run more than once. Cloudflare can remove an idle object from memory and create a new class instance when another request arrives.
CREATE TABLE IF NOT EXISTS and INSERT OR IGNORE make the setup safe to repeat.
The class instance is temporary. The SQLite data survives.
Notice that sql.exec() is synchronous. We do not use await for SQL queries.
Add methods to read and change the value
Workers communicate with Durable Objects through RPC methods.
Any public method on the class can be called from a Durable Object stub.
First add a method that reads the value:
async getValue(): Promise<number> {
const row = this.ctx.storage.sql
.exec<{ value: number }>(
'SELECT value FROM counter WHERE id = 1',
)
.one()
return row.value
}
sql.exec() returns a cursor. We use .one() because this query must return exactly one row.
Now add the increment method:
async increment(amount = 1): Promise<number> {
const row = this.ctx.storage.sql
.exec<{ value: number }>(
`UPDATE counter
SET value = value + ?
WHERE id = 1
RETURNING value`,
amount,
)
.one()
return row.value
}
The ? is a SQL parameter. Its value comes from the second argument to sql.exec().
Using a parameter keeps the value separate from the SQL string.
Finally, add a reset method:
async reset(): Promise<number> {
this.ctx.storage.sql.exec(
'UPDATE counter SET value = 0 WHERE id = 1',
)
return 0
}
These methods look like normal TypeScript methods.
Cloudflare turns their arguments and return values into RPC messages for us.
Call the object from the Worker
Durable Objects do not receive public Internet traffic directly.
A Worker receives the request, chooses an object, and calls it through the binding.
Add this below the Counter class:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
const name = url.searchParams.get('name') ?? 'default'
const counter = env.COUNTERS.getByName(name)
if (request.method === 'POST' && url.pathname === '/increment') {
const value = await counter.increment()
return Response.json({ name, value })
}
if (request.method === 'POST' && url.pathname === '/reset') {
const value = await counter.reset()
return Response.json({ name, value })
}
if (request.method === 'GET' && url.pathname === '/') {
const value = await counter.getValue()
return Response.json({ name, value })
}
return Response.json({ error: 'Not found' }, { status: 404 })
},
} satisfies ExportedHandler<Env>
The most important line is:
const counter = env.COUNTERS.getByName(name)
getByName('page-views') always refers to the same logical object inside the COUNTERS namespace.
getByName('downloads') refers to another object.
The returned value is a stub. A stub is a client for one Durable Object. Calling counter.increment() sends an RPC call to that object.
The object might run in another location, so always await RPC calls.
Run it locally
Start Wrangler:
npm run dev
Wrangler normally starts the Worker at http://localhost:8787.
Read the default counter:
curl http://localhost:8787/
You should get:
{
"name": "default",
"value": 0
}
Increment it:
curl -X POST http://localhost:8787/increment
The value is now 1:
{
"name": "default",
"value": 1
}
Run the command again. The value becomes 2.
Stop the development server, start it again, and read the counter. Wrangler keeps the local Durable Object storage, so the value is still there.
Names create different objects
Now use a name:
curl -X POST \
'http://localhost:8787/increment?name=page-views'
Run it twice.
Then create another counter:
curl -X POST \
'http://localhost:8787/increment?name=downloads'
Read both values:
curl 'http://localhost:8787/?name=page-views'
curl 'http://localhost:8787/?name=downloads'
You should see 2 for page-views and 1 for downloads.
These are not two rows in one shared database.
They are two Durable Objects. Each one has its own class instance and its own SQLite database.
COUNTERS namespace
├── page-views object
│ └── private SQLite database
└── downloads object
└── private SQLite database
This is how Durable Objects scale.
You do not send every request through one global object. You split the application around independent units of coordination.
Choose the object boundary
The name passed to getByName() defines the object boundary.
This is the most important design decision you make.
For example:
const room = env.CHAT_ROOMS.getByName(roomId)
const game = env.GAMES.getByName(gameId)
const document = env.DOCUMENTS.getByName(documentId)
const limiter = env.RATE_LIMITERS.getByName(userId)
Each room, game, document, or user gets an independent object.
Requests for the same name meet at the same object. Requests for different names do not wait for each other.
Avoid this unless the complete application truly needs one global owner:
const object = env.OBJECTS.getByName('global')
One global object becomes a bottleneck. It also puts unrelated state in the same failure and storage boundary.
My advice is to finish this sentence:
There must be exactly one owner for each ________.
The answer is usually your Durable Object name.
Why concurrent increments are safe
Let’s send 20 increments at once:
seq 1 20 | xargs -P20 -I{} \
curl -s -X POST \
'http://localhost:8787/increment?name=concurrent'
Read the result:
curl 'http://localhost:8787/?name=concurrent'
The value is 20.
All requests for concurrent reached the same logical object. The SQL update changed the value atomically inside its local database.
Durable Objects also use input and output gates around storage operations.
An input gate prevents another event from observing storage in the middle of protected storage work. An output gate prevents the object from confirming a result before its writes are durable.
You normally do not write locks around local Durable Object storage.
But this protection does not make every asynchronous operation atomic.
Be careful with external I/O
JavaScript can run another request while the current request waits for non-storage I/O.
For example:
async updateFromAnotherService() {
const before = await this.getValue()
const response = await fetch('https://api.flaviocopes.com/value')
const { amount } = await response.json<{ amount: number }>()
this.ctx.storage.sql.exec(
'UPDATE counter SET value = ? WHERE id = 1',
before + amount,
)
}
Another request can change the counter while fetch() waits.
The value stored in before may be stale when the external request finishes.
Do not hold an imagined lock across fetch(), R2, KV, or another external service. Persist enough state before the call, then verify it again afterward.
blockConcurrencyWhile() can block all events, but it is not a fix for slow external calls. Use it only for short initialization and storage migrations.
Memory is not durable
You can use class properties inside a Durable Object:
private requestsSinceStart = 0
This value is fast, but temporary.
Cloudflare can evict an idle object from memory. The next request creates another class instance, and requestsSinceStart returns to 0.
SQLite survives that lifecycle.
Use memory for caches and active connections. Store important state in this.ctx.storage before returning success.
This is why the product is called a Durable Object, not a permanent JavaScript object.
The identity and stored state are durable. The in-memory instance is not.
The complete code
Here is the complete src/index.ts file:
import { DurableObject } from 'cloudflare:workers'
interface Env {
COUNTERS: DurableObjectNamespace<Counter>
}
export class Counter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS counter (
id INTEGER PRIMARY KEY CHECK (id = 1),
value INTEGER NOT NULL
);
INSERT OR IGNORE INTO counter (id, value)
VALUES (1, 0);
`)
}
async getValue(): Promise<number> {
const row = this.ctx.storage.sql
.exec<{ value: number }>(
'SELECT value FROM counter WHERE id = 1',
)
.one()
return row.value
}
async increment(amount = 1): Promise<number> {
const row = this.ctx.storage.sql
.exec<{ value: number }>(
`UPDATE counter
SET value = value + ?
WHERE id = 1
RETURNING value`,
amount,
)
.one()
return row.value
}
async reset(): Promise<number> {
this.ctx.storage.sql.exec(
'UPDATE counter SET value = 0 WHERE id = 1',
)
return 0
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
const name = url.searchParams.get('name') ?? 'default'
const counter = env.COUNTERS.getByName(name)
if (request.method === 'POST' && url.pathname === '/increment') {
const value = await counter.increment()
return Response.json({ name, value })
}
if (request.method === 'POST' && url.pathname === '/reset') {
const value = await counter.reset()
return Response.json({ name, value })
}
if (request.method === 'GET' && url.pathname === '/') {
const value = await counter.getValue()
return Response.json({ name, value })
}
return Response.json({ error: 'Not found' }, { status: 404 })
},
} satisfies ExportedHandler<Env>
The example is small, but it contains the complete Durable Objects path:
- The Worker receives a request.
getByName()selects one object.- The Worker calls a public RPC method through the stub.
- The object reads or writes its private SQLite database.
- The RPC result returns to the Worker.
- The Worker sends the HTTP response.
The same path works for much larger applications.
Deploy the Worker
Log in to Cloudflare:
npx wrangler login
Deploy the project:
npx wrangler deploy
Wrangler prints a workers.dev URL.
Test the deployed Worker:
curl -X POST \
'https://durable-objects-tutorial.YOUR-SUBDOMAIN.workers.dev/increment?name=page-views'
The first deployment provisions the Counter namespace declared in exports.
Later deployments keep that namespace and its data.
Be careful when deleting or renaming a Durable Object class. Those changes can remove or move stored data. Follow Cloudflare’s Durable Object class exports documentation before changing a deployed class lifecycle.
Where to go next
Our object receives RPC calls and stores data in SQLite.
Durable Objects can also do two things that fit the same model.
WebSockets
One object can own all live connections for a chat room, game, or collaborative document.
The Hibernatable WebSockets API lets Cloudflare remove the JavaScript instance from memory while clients stay connected. The object wakes when a message arrives.
The object name still defines the boundary. One room name selects one room object and its connections.
Alarms
Each Durable Object can schedule one alarm.
An alarm wakes the object at a future time. You can use it to expire sessions, retry work, close an inactive room, or process the next scheduled item.
Calling setAlarm() again replaces the current alarm, so applications with several scheduled items normally store them in SQLite and set the alarm for the next one.
WebSockets and alarms add capabilities. They do not change the core model we built in this tutorial.
When I would use Durable Objects
I would use a Durable Object when an application needs one authoritative owner for each independent piece of state.
Good examples include:
- a chat room with connected users
- a multiplayer game or lobby
- a collaborative document
- a per-user rate limiter
- a device session
- an inventory or booking boundary
- a long-lived AI agent
I would not use one for static content, large files, general analytics, or independent requests.
For those cases:
- use a plain Worker for stateless request handling
- use D1 for relational data queried across many entities
- use KV for globally read configuration or cached values
- use R2 for files and large objects
My free Cloudflare storage chooser can help when the boundary is not obvious.
The object is not just a database.
It is the place where code, identity, storage, and coordination meet.
Once that clicks, the rest of the API becomes much easier to understand.
The official Cloudflare Durable Objects documentation has the complete API and more examples.
Related posts about cloudflare: