Distributed systems
Part 2 of 6 · Durable ObjectsDurable Objects Storage - SQLite vs Legacy KV, Transactions, Write Coalescing & Point-in-Time Recovery
Storage: SQLite backend vs legacy KV, SQL API, sync KV API, transactionSync, write coalescing, in-memory cache, allowUnconfirmed/noCache, PITR bookmarks with ctx.abort, limits quoted from docs; output-gate and PITR simulations; storage decision chart.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Which storage backend do new classes get?
Answer
Embedded SQLite, with SQL, a synchronous KV API, alarms and PITR.
L2
When does a write become durable?
Answer
When followers acknowledge the commit, about 3 of 5 per Cloudflare's engineering post.
L3
When does the client see the reply?
Answer
Only after the batch is durable, because the output gate holds outgoing messages.
L4
How do you make two updates atomic?
Answer
Issue them with no await between them, or use transactionSync for conditional logic.
L5
What happens at 10 GB?
Answer
Writes fail with SQLITE_FULL while reads and deletes still work.
L6
How do you undo a bad DELETE in one tenant?
Answer
Restore a bookmark from before the mistake with onNextSessionRestoreBookmark and ctx.abort.
L7
SQLite in a Durable Object vs D1?
Answer
D1 is a managed database over the network. SQLite in a DO is one database per object with zero-hop reads.
Failure modes
A crash before the batch is durable
The batch is lost and the held reply is discarded, so the caller gets an error instead of a false acknowledgement.
An object hits its size limit
Writes fail with SQLITE_FULL until the entity is sharded or cold data moves out.
A bad statement wipes one tenant's data
Without a restore plan the data is gone. PITR restores just that object to a bookmark.
Misconceptions
You need BEGIN and COMMIT for atomic writes.
Synchronous writes coalesce automatically, and BEGIN or SAVEPOINT are not allowed in sql.exec. Use transactionSync.
allowUnconfirmed is a free speedup.
It lets replies leave before the write is durable, so a crash can lose an acknowledged write.
Storage spans objects like a normal database.
Each object's database is private. There are no cross-object transactions.
Interviewer traps
Putting an await between the check and the write.
Keep the read-modify-write synchronous so it coalesces into one transaction.
Running schema migrations from a separate script.
Run them inside each object, in the constructor under blockConcurrencyWhile.
Design scenario
Same prompt for every reader.
Requirements
Atomic transfers, no acknowledged-but-lost writes, and recovery of one tenant without touching others.
Failure assumptions
- A machine can die mid-batch.
- An operator can run a DELETE without WHERE.
- Some tenants grow toward the size limit.
Constraints
- No cross-object transactions.
- Recovery must not roll back other tenants.
Prompt
Store a per-tenant ledger in Durable Objects with transfers, audit logs and a recovery story.
API
Which RPC methods mutate the ledger, and what do they return?
Data
Which tables and indexes live in each tenant's SQLite?
Architecture
Where do PITR bookmarks, cold-data export and reporting fit?
Overview
Every Durable Object has its own private database that only it can touch. New classes get an embedded SQLite database (SQL plus a key-value API on top, point-in-time recovery, up to 10 GB per object). Older classes may still use the legacy KV backend (async key-value only). Because the database lives inside the object and the object is single-threaded, reads come from local disk or memory, and a run of writes with no await between them commits as one atomic batch. The reply to the caller is held back until that batch is durable. You get transactional, strongly consistent storage with very low read latency, but scoped to one object: there are no cross-object transactions.
How the code was checked: The real Durable Object TypeScript on this page type-checks with
tsc --strictagainst@cloudflare/workers-typesand was run locally underwrangler dev4.148.0 (workerd 2026-10-06) and the@cloudflare/vitest-plugintest pool. Blocks labeled simulation are sandbox models of the semantics, not Cloudflare code. Limits and prices are quoted from Cloudflare's docs as of 2026-10-07.
Why this matters
- Interviews: "Where is the data, how is it made durable, and what is atomic?" Being able to say "writes are coalesced into an implicit transaction and the output gate holds the response until the commit is confirmed on 3 of 5 followers" shows you understand durability rather than just the API.
- Production: the storage model decides your schema (one SQLite DB per entity), your migration story (run migrations inside each object, lazily), your recovery story (per-object PITR) and your cost (rows read and written, billed like D1).
SQLite backend vs legacy KV backend
| SQLite-backed (recommended) | KV-backed (legacy) | |
|---|---|---|
| Availability | All new classes; the only option on the Workers Free plan | Only for accounts that already have a KV-backed namespace |
| APIs | ctx.storage.sql, synchronous ctx.storage.kv, async KV, alarms, PITR | Async KV and alarms only |
| Size limits (docs) | 10 GB per object (Workers Paid); key plus value up to 2 MB; row or string up to 2 MB | Unlimited per object; key 2 KiB, value 128 KiB |
| Transactions | Implicit (write coalescing) plus transactionSync(); BEGIN / SAVEPOINT are not allowed in sql.exec | Implicit coalescing plus transaction(txn => ...) |
| Point-in-time recovery | Yes, any point in the last 30 days | No |
| Billing (Workers Paid) | Rows read: first 25 billion per month included, then $0.001 per million. Rows written: first 50 million included, then $1.00 per million. Storage: 5 GB-month included, then $0.20 per GB-month | Request units of 4 KB for reads and writes, plus stored GB-month |
| Free plan storage | 5 GB total per account; the limits FAQ says 1 GB per object on Free | Not available |
The docs also note: SQL limits include 100 columns per table, 100 KB per statement and 100 bound parameters per query. KV methods on a SQLite object store data in a hidden table named __cf_kv. Every index row updated counts as an extra row written. When an object hits its size limit, writes fail with SQLITE_FULL while reads and deletes keep working. PRAGMA user_version is not supported, so track schema versions in your own table.
How do writes become safe to acknowledge?
Prefer
Coalesced writes behind the output gate
Synchronous writes commit as one batch, and replies wait until the batch is durable.
- Three writes in a transfer land together.
- A crash discards the held reply instead of lying.
- PITR can roll one object back 30 days.
Alternative
Acknowledge before durability
allowUnconfirmed or awaits between writes trade safety for latency.
- An acknowledged write can vanish in a crash.
- Interleaved awaits split one logical update.
- No undo for a bad statement without PITR.
From a write to a durable reply
Diagram 1 condensed. The simulations show the happy path and the crash.
- 1
Write synchronously
Statements with no await between them join one implicit transaction. - 2
Hold the reply
The output gate keeps outgoing messages until the batch commits. - 3
Confirm on a quorum
Followers acknowledge the commit before the reply leaves. - 4
Crash before commit
The batch and the held reply are dropped, and the caller sees an error.
How a write becomes durable (step-labeled)
Decisions
- 1
1. Method runs sql.exec INSERT (synchronous)
- next2. Change lands in local SQLite and the pending batch
- 2
2. Change lands in local SQLite and the pending batch
- next3. More writes with no await join the same batch
- 3
3. More writes with no await join the same batch
- next4. Method returns, reply is queued behind the output gate
- 4
4. Method returns, reply is queued behind the output gate
- next5. Batch committed, change log sent to 5 followers
- 5
5. Batch committed, change log sent to 5 followers
- next6. At least 3 followers acknowledged?
- ?
6. At least 3 followers acknowledged?
- yes7. Output gate opens, reply is delivered
- F1. no, write failsObject resets, queued replies become errors, nothing partial is visible
- 7
7. Output gate opens, reply is delivered
- next8. Changes later batched to object storage, which backs PITR
- 8
8. Changes later batched to object storage, which backs PITR
- 9
Object resets, queued replies become errors, nothing partial is visible
Lesson map
Durable Objects Storage - SQLite vs Legacy KV, Transactions, Write Coalescing & Point-in-Time Recovery
Storage: SQLite backend vs legacy KV, SQL API, sync KV API, transactionSync, write coalescing, in-memory cache, allowUnconfirmed/noCache, PITR bookmarks with ctx.abort, limits quoted from docs; output-gate and PITR simulations; storage decision chart.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["1. Method runs sql.exec INSERT (synchronous)"] b["2. Change lands in local SQLite and the pending batch"] c["3. More writes with no await join the same batch"] d["4. Method returns, reply is queued behind the output gate"] e["5. Batch committed, change log sent to 5 followers"] f["6. At least 3 followers acknowledged?"] g["7. Output gate opens, reply is delivered"] h["8. Changes later batched to object storage, which backs PITR"] x["Object resets, queued replies become errors, nothing partial is visible"] a -->|continues| b b -->|continues| c c -->|continues| d d -->|continues| e e -->|continues| f f -->|yes| g g -->|continues| h f -->|F1. no, write fails| x
Steps 5 to 8 describe Cloudflare's Storage Relay Service from the engineering post on SQLite in Durable Objects: followers acknowledge, and changes are uploaded to object storage in batches of up to 10 seconds or 16 MB, which is what makes 30-day recovery possible.
Real code: schema setup and atomic writes
The constructor creates the schema inside blockConcurrencyWhile, so no request can run before the table exists. Increments are a single UPSERT with RETURNING, which is atomic because it is one synchronous statement.
// Counter: one object per counter name. Sharded counters spread one hot name across N objects.
import { DurableObject } from "cloudflare:workers";
import type { Env } from "./index";
export class Counter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS kv(k TEXT PRIMARY KEY, v INTEGER NOT NULL)");
});
}
// RPC method: callers do `await stub.increment(5)`; no Request/Response parsing.
increment(by = 1): number {
// UPSERT + RETURNING is one synchronous statement: atomic, no await, no interleaving.
return this.ctx.storage.sql
.exec("INSERT INTO kv(k, v) VALUES ('n', ?) ON CONFLICT(k) DO UPDATE SET v = v + excluded.v RETURNING v", by)
.one().v as number;
}
get(): number {
const rows = this.ctx.storage.sql.exec("SELECT v FROM kv WHERE k = 'n'").toArray();
return rows.length ? (rows[0].v as number) : 0;
}
}
// Worker-side helpers for a sharded counter: writes pick a random shard, reads fan in all shards.
export async function shardedIncrement(env: Env, name: string, shards: number): Promise<number> {
const shard = Math.floor(Math.random() * shards);
return env.COUNTER.getByName(`${name}#${shard}`).increment(1);
}
export async function shardedRead(env: Env, name: string, shards: number): Promise<{ total: number; perShard: number[] }> {
const perShard = await Promise.all(
Array.from({ length: shards }, (_, i) => env.COUNTER.getByName(`${name}#${i}`).get()),
);
return { total: perShard.reduce((a, b) => a + b, 0), perShard };
}The rate limiter shows the common "in-memory copy plus persisted truth" pattern: it loads once in the constructor, mutates the field, and writes it back on every call without awaiting. The output gate guarantees the client never sees a decision that was not persisted.
// RateLimiter: one object per (tenant or API key). Token bucket persisted in the object's SQLite.
import { DurableObject } from "cloudflare:workers";
import type { Env } from "./index";
export type Decision = { allowed: boolean; remaining: number; retryAfterMs: number };
export class RateLimiter extends DurableObject<Env> {
private tokens = -1; // in-memory copy; -1 means "not loaded yet" (fresh instance or after eviction)
private updated = 0;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS bucket(id INTEGER PRIMARY KEY CHECK(id = 1), tokens REAL, updated INTEGER)");
const row = ctx.storage.sql.exec("SELECT tokens, updated FROM bucket").toArray()[0];
if (row) { this.tokens = row.tokens as number; this.updated = row.updated as number; }
});
}
// capacity = burst size, refillPerSec = sustained rate. Whole method is synchronous => atomic per key.
take(capacity: number, refillPerSec: number, cost = 1): Decision {
const now = Date.now();
if (this.tokens < 0) { this.tokens = capacity; this.updated = now; }
// Refill based on elapsed time, capped at capacity.
this.tokens = Math.min(capacity, this.tokens + ((now - this.updated) / 1000) * refillPerSec);
this.updated = now;
let allowed = false;
if (this.tokens >= cost) { this.tokens -= cost; allowed = true; }
// Persist so an eviction cannot hand a client a fresh full bucket. Output gate holds the reply until durable.
this.ctx.storage.sql.exec(
"INSERT INTO bucket(id, tokens, updated) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET tokens = excluded.tokens, updated = excluded.updated",
this.tokens, this.updated);
const deficit = Math.max(0, cost - this.tokens);
return { allowed, remaining: Math.floor(this.tokens), retryAfterMs: allowed ? 0 : Math.ceil((deficit / refillPerSec) * 1000) };
}
}Write coalescing and the output gate (simulation)
The rules from the docs: writes with no intervening await are combined and submitted atomically, so after a machine failure either all of them are on disk or none are. Outgoing messages wait for the write to be confirmed. If the write fails, the object is reset and the held messages are replaced with errors. This simulation (not Cloudflare code) shows the three cases that matter, including the classic legacy-KV mistake of awaiting between a debit and a credit.
# SIMULATION (not Cloudflare code) of write coalescing + the output gate.
# Writes go to a buffer; consecutive writes with no await in between commit as ONE atomic batch.
# Outgoing messages (the reply) are held until the batch is durable. A failed flush discards the reply.
class Obj:
def __init__(self):
self.disk, self.buffer, self.batches, self.outbox = {}, {}, [], []
def put(self, k, v): # like ctx.storage.sql.exec / storage.put without await
self.buffer[k] = v
def await_point(self, crash=False): # any await: the pending batch is flushed (or lost on crash)
if self.buffer:
if crash:
self.buffer.clear(); self.outbox.clear() # object resets, held replies are replaced by errors
return "CRASH: batch lost, held reply discarded, caller gets an error"
self.disk.update(self.buffer); self.batches.append(dict(self.buffer)); self.buffer.clear()
return "flushed"
def reply(self, msg): # held by the output gate until the next flush succeeds
self.outbox.append(msg)
def deliver(self):
if self.buffer: # a write is still pending: output gate is closed
return []
sent, self.outbox = self.outbox, []
return sent
print("A) transfer, 3 writes, no await between them")
o = Obj(); o.disk = {"alice": 100, "bob": 0}
o.put("alice", 70); o.put("bob", 30); o.put("log:1", "alice->bob 30"); o.reply("200 transfer ok")
print(" before flush, client sees:", o.deliver() or "nothing yet (output gate closed)")
print(" ", o.await_point(), "| batches:", len(o.batches), "| client sees:", o.deliver(), "| disk:", o.disk)
print("B) same transfer, machine dies before the batch is durable")
o = Obj(); o.disk = {"alice": 100, "bob": 0}
o.put("alice", 70); o.put("bob", 30); o.reply("200 transfer ok")
print(" ", o.await_point(crash=True), "| disk:", o.disk, "| client sees:", o.deliver())
print("C) legacy style: await between debit and credit (two batches)")
o = Obj(); o.disk = {"alice": 100, "bob": 0}
o.put("alice", 70); print(" debit:", o.await_point())
o.put("bob", 30); print(" credit:", o.await_point(crash=True))
print(" disk after crash:", o.disk, "<- debit committed, credit lost: money vanished")Output (simulation):
A) transfer, 3 writes, no await between them
before flush, client sees: nothing yet (output gate closed)
flushed | batches: 1 | client sees: ['200 transfer ok'] | disk: {'alice': 70, 'bob': 30, 'log:1': 'alice->bob 30'}
B) same transfer, machine dies before the batch is durable
CRASH: batch lost, held reply discarded, caller gets an error | disk: {'alice': 100, 'bob': 0} | client sees: []
C) legacy style: await between debit and credit (two batches)
debit: flushed
credit: CRASH: batch lost, held reply discarded, caller gets an error
disk after crash: {'alice': 70, 'bob': 0} <- debit committed, credit lost: money vanishedTransactions: which tool when
| Need | SQLite-backed object | Legacy KV object |
|---|---|---|
| A few writes that must commit together | Just issue them with no await between (coalesced) | Same |
| Multi-statement logic with rollback on error | ctx.storage.transactionSync(() => { ... }) (callback must be synchronous) | await ctx.storage.transaction(async txn => { ... }) and call methods on txn |
| Read, call an external API, then write | No storage transaction can span the external call. Use claim-first or a version check (see the concurrency page) | Same |
| Send a reply before the write is durable | put(..., { allowUnconfirmed: true }), then await ctx.storage.sync() where it matters | Same |
Decision chart: which storage tool for this write?
Decisions
- 1
1. New class or existing KV-backed class?
- newSQLite backend (only option for new namespaces)
- existing KV-backedAsync KV API plus transaction(txn)
- 2
SQLite backend (only option for new namespaces)
- next2. Relational queries or indexes needed?
- 3
Async KV API plus transaction(txn)
- ?
2. Relational queries or indexes needed?
- yessql.exec with tables and indexes
- noctx.storage.kv (synchronous key-value)
- 5
sql.exec with tables and indexes
- next3. Several writes must commit together?
- next4. Value larger than 2 MB or a file?
- 6
ctx.storage.kv (synchronous key-value)
- next3. Several writes must commit together?
- ?
3. Several writes must commit together?
- no awaits between themRely on write coalescing
- conditional logic with rollbacktransactionSync callback
- an external call sits in the middleNo storage transaction can span it: claim first or version check
- 8
Rely on write coalescing
- 9
transactionSync callback
- 10
No storage transaction can span it: claim first or version check
- ?
4. Value larger than 2 MB or a file?
- yesPut the blob in R2, keep the key in SQLite
- 12
Put the blob in R2, keep the key in SQLite
A gotcha from the API reference: a SQL cursor held across an await has no snapshot isolation and can observe later writes, including ones that later roll back. Consume cursors synchronously with .toArray() or .one().
The in-memory cache and in-memory state
There are three layers:
- Instance fields (
this.tokens): fastest, gone on eviction, hibernation, deploy or crash. - The storage layer's own cache: recently read or written values come back instantly from memory, so
get()on a hot key is cheap. PassnoCachefor keys you will not touch again soon. It is only a hint and never changes semantics. - Durable SQLite: survives restarts and is the source of truth.
The docs warn that a burst of put() calls with no awaits can grow the write buffer toward the isolate's 128 MB memory limit. Awaiting the promise applies backpressure.
Point-in-time recovery
SQLite-backed objects can be restored to any point in the last 30 days with no setup. The API works with bookmarks, strings that sort in time order:
getCurrentBookmark()returns "now".getBookmarkForTime(ts)returns approximately that moment (within 30 days).onNextSessionRestoreBookmark(b)schedules the restore for the next restart and returns an undo bookmark. Then callctx.abort()to restart.
Real, type-checked helper (PITR is a production feature, so this was type-checked, not run locally):
// Point-in-time recovery for ONE SQLite-backed object (type-checked; PITR is a production feature).
// Call from an admin-only RPC method on the object, e.g. `await restoreTo(this.ctx, Date.now() - 15 * 60e3)`.
export async function restoreTo(ctx: DurableObjectState, when: number): Promise<{ target: string; undo: string }> {
const target = await ctx.storage.getBookmarkForTime(when); // must be within the last 30 days
const undo = await ctx.storage.onNextSessionRestoreBookmark(target); // bookmark to roll the restore back
// Store `undo` somewhere OUTSIDE this object (log, KV, another DO) before restarting.
ctx.abort("restoring to bookmark " + target); // restart; the new session sees restored data
return { target, undo }; // not reached: abort() resets the object
}Simulation of what a restore keeps and loses (not Cloudflare code):
// SIMULATION (not Cloudflare code) of point-in-time recovery with lexically comparable bookmarks.
// The real API: getCurrentBookmark(), getBookmarkForTime(ts), onNextSessionRestoreBookmark(b) + ctx.abort().
type Entry = { bookmark: string; at: number; op: string; apply: (db: Map<string, number>) => void };
const log: Entry[] = [];
let seq = 0;
const bm = (n: number) => n.toString(16).padStart(8, "0") + "-" + "c0ffee"; // sortable like the real ones
function write(at: number, op: string, apply: Entry["apply"]) { log.push({ bookmark: bm(++seq), at, op, apply }); }
function replay(upTo: string): Map<string, number> {
const db = new Map<string, number>();
for (const e of log) if (e.bookmark <= upTo) e.apply(db); // plain string comparison orders bookmarks
return db;
}
const bookmarkForTime = (t: number) => [...log].reverse().find((e) => e.at <= t)!.bookmark;
write(1000, "INSERT order 1", (db) => db.set("order:1", 40));
write(2000, "INSERT order 2", (db) => db.set("order:2", 25));
write(3000, "UPDATE order 1", (db) => db.set("order:1", 45));
write(4000, "DELETE FROM orders -- forgot WHERE", (db) => db.clear());
write(4500, "INSERT order 3 (after the bug)", (db) => db.set("order:3", 10));
const head = log[log.length - 1].bookmark;
console.log("current state @", head, Object.fromEntries(replay(head)));
const target = bookmarkForTime(3999); // just before the bad DELETE
const undo = head; // real API returns an 'undo' bookmark for the restore
console.log("restore target @", target, Object.fromEntries(replay(target)));
console.log("lost by restoring:", JSON.stringify(log.filter((e) => e.bookmark > target).map((e) => e.op)));
console.log("undo bookmark @", undo, "(restore again to this to roll back the recovery)");
console.log("bookmarks sortable:", bm(9) < bm(10), "| PITR window in the docs: 30 days");Output (simulation):
current state @ 00000005-c0ffee { 'order:3': 10 }
restore target @ 00000003-c0ffee { 'order:1': 45, 'order:2': 25 }
lost by restoring: ["DELETE FROM orders -- forgot WHERE","INSERT order 3 (after the bug)"]
undo bookmark @ 00000005-c0ffee (restore again to this to roll back the recovery)
bookmarks sortable: true | PITR window in the docs: 30 daysExpectedcurrent state @ 00000005-c0ffee { 'order:3': 10 } restore target @ 00000003-c0ffee { 'order:1': 45, 'order:2': 25 } lost by restoring: ["DELETE FROM orders -- forgot WHERE","INSERT order 3 (after the bug)"] undo bookmark @ 00000005-c0ffee (restore again to this to roll back the recovery) bookmarks sortable: true | PITR window in the docs: 30 days
Press Run. Snippets must be self-contained — no network, files, or native modules.
PITR is per object. Restoring one tenant's object does not touch any other tenant, which is a real advantage over restoring a shared database. The cost is that writes made after the target point (order 3 above) are lost unless you replay them.
Schema migrations
Each object runs its own migrations when it starts. Thousands of objects therefore migrate lazily, each on its first request after the deploy:
- Keep a
_schema_migrationstable (sincePRAGMA user_versionis unsupported) or use a helper such asdurable-utilsor the@cloudflare/actorsstorage utilities, which the docs point to. - Run pending steps inside
blockConcurrencyWhilein the constructor, and keep them fast: the callback has a 30-second timeout and a throw resets the object. - Prefer additive changes (expand, then contract). Old code may still be running elsewhere for a while during a rollout.
What happens if you choose otherwise
- Keep state only in memory: fastest, until the first eviction or deploy silently resets your counter.
- Use the async KV API with an
awaitbetween related writes: each await ends the batch, so a crash can leave half an operation committed. - Keep the data in D1 and use the object only as a lock: every operation becomes a network hop to the database. You gain cross-entity SQL and lose colocated, near-zero-latency reads.
- Store large blobs in the object: the 2 MB row limit and 10 GB object limit bite, and you pay rows written. Put blobs in R2 and keep keys in SQLite.
Pitfalls
- An object only ever sees its own database. Cross-object invariants need a saga, an outbox, or a coordinating object.
- JavaScript numbers lose precision above 2^53. Store big integers as text or BigInt-safe encodings.
- Dropping tables does not clear everything. Use
deleteAll(). On compatibility dates from 2026-02-24 it also deletes the alarm. deleteAll()on SQLite objects is atomic. On the KV backend it can fail partway and must be retried.
Interview Q&A
When does a write in a Durable Object become durable, and when does the client find out?
Answer
The write goes to local SQLite synchronously. The commit is forwarded to followers and confirmed once a quorum (3 of 5, per Cloudflare's engineering post) acknowledges. The output gate holds every outgoing message until then, so the client can never see an acknowledgement for data that might be lost.
How do you make two updates atomic?
Answer
Issue them with no await between them. They are coalesced into one implicit transaction. For conditional multi-step logic use transactionSync() on SQLite objects.
How do you recover from a bad `DELETE` in one tenant's object?
Answer
Get a bookmark for just before the mistake, call onNextSessionRestoreBookmark, save the returned undo bookmark, then ctx.abort(). Only that object is rolled back.
SQLite in a Durable Object vs D1?
Answer
D1 is a managed database you reach over the network, with an HTTP API, migrations and import or export. SQLite in a DO is a building block: one database per object, zero-hop reads, and logic colocated with data. Cloudflare says the query pricing and limits are intended to match.
What breaks when an object reaches 10 GB?
Answer
Writes fail with SQLITE_FULL. Reads and deletes still work. The fix is to shard the entity or move cold data out (R2 or D1).
What do the synchronous KV API and the SQL API share on a SQLite object?
Answer
Both write to the same SQLite database. KV methods store data in a hidden table named __cf_kv.
Why do index-heavy tables cost more?
Answer
Every index row updated counts as an extra row written, and rows written are billed.
How do you run schema migrations in Durable Objects?
Answer
Inside each object, usually in the constructor under blockConcurrencyWhile, tracked with your own version table because PRAGMA user_version is not supported.
Check yourself
Write down one multi-step update in a service you know. Rewrite it so every step runs synchronously in one object, and mark where an await would break atomicity.
Elsewhere in the library
These pages stay as they are. This lesson only points at them: Database Storage Engines — WAL, B-Trees & LSM Trees, Backups That Actually Restore - Snapshots, PITR, Immutable Copies & Restore Drills, MVCC, Snapshot Isolation & Write Skew, Zero-Downtime Database Migrations — Expand/Contract, Dual Write & Online DDL.