Running your database inside your object

Cloud databases generally live on a separate machine from your application, which means every query crosses a network and has to be synchronized against other clients hitting the same data. Durable Objects (DOs) flip that model: your code executes on the same machine as its storage — not just the same rack or process, but the same thread. With data cached locally, a query can return in microseconds with no context switch at all.

Until recently, DO storage was limited to key-value pairs. Now DOs support full SQL with tables and indexes, powered by SQLite embedded directly in the object. SQLite is already the most widely deployed SQL engine in the world, found in billions of phones, desktops and embedded devices. But it has rarely been used on servers, where the standard architecture favors large distributed databases separate from application code. Durable Objects change that equation by making SQLite a local library in your application.

What a Durable Object is

A DO is a small, addressable server on Cloudflare's Workers platform. It keeps state in memory and on disk, and any Worker anywhere on the network can send it messages by name; all messages to a given name will always reach the same instance. DOs are designed to be small and numerous — an application can create billions of them distributed globally. Cloudflare chooses where each object lives based on access patterns, starts it on demand and shuts it down when idle.

Because exactly one object exists per name, a DO can coordinate operations on the same logical state. A real-time collaborative editor might create one DO per document: the object receives edits, resolves conflicts, broadcasts updates and persists content. This pattern generalizes to any stateful server logic — DOs are a basic building block for distributed systems.

One important constraint: DOs scale out, not up. A single object runs on a single thread of one machine, so throughput per object is limited. To handle more load you create more objects, which works best when state can be partitioned into logical units like documents, users or database shards. When many clients need to mutate the same state — a vote counter receiving a million votes, for example — you would need multiple objects replicating state to each other, whether through CRDTs, gossip or a fan-in/fan-out arrangement with a single primary.

Why SQLite-in-DO is fast

In traditional setups, stateless app servers talk to a database over a network, incurring latency measured in milliseconds even on a local connection. In a DO, SQLite runs as a library in the same thread as your code. There is no communication barrier to cross and no I/O wait in the common case — queries complete in microseconds.

Synchronous queries without the event-loop hazard

The SQL API in DOs returns results synchronously; you do not await a query. That surprises developers who assume database access is inherently asynchronous I/O. In practice it's safe because the data is likely already in memory, and a local SSD is effectively another cache layer when it isn't. Synchronous calls also avoid the classic async bug: when your code awaits a promise, other code can run and change the world before the await resolves. With straight-line code, statements execute in the order written, uninterrupted.

Synchronous queries are also faster in the common case — an asynchronous event loop has overhead even when nothing needs to wait.

Writes confirmed without blocking

The harder question is durability. A write to cache isn't enough; before telling a client a write succeeded, you must confirm the data actually reached disk or network storage and won't be lost on power failure. Databases normally confirm every write before returning, which can be slow. Asynchronous code handles that by doing other work while the write finishes.

But DO writes are synchronous, so a write that waits for confirmation would pause the whole program and hurt throughput. The solution is an "Output Gate." When the application issues a write, execution continues immediately without waiting for confirmation. When the DO later responds to the client, the response is held at an Output Gate until all storage writes triggered by that request have been confirmed. Only then is the response sent. If a write fails, the response is replaced with an error and the DO restarts — so a success response can never reach a client unless the data is actually durable.

The net effect: requests are handled serially without interleaving, yet responses go out sooner than they would if the application had paused for each confirm. You get the simplicity of synchronous code plus lower latency and no throughput loss.

The N+1 problem disappears

Zero-latency queries change how you structure code. Consider the classic N+1 problem: fetching 100 blog authors, then querying each one's posts individually — 101 queries total. Against a network database at 5ms round-trip that costs 505ms, which is why experienced SQL users rewrite it as a single join. But joins are subtle (inner vs. left vs. right), and the rewrite obfuscates the logic.

When SQLite is a library inside your application, that optimization is unnecessary. The naïve 101-query version performs about the same as the single fancy query. You can keep your logic in application code using basic SQL statements, without mastering advanced join syntax. SQLite's own authors have made this point: the N+1 problem is fundamentally a network-latency problem, not a query-count problem.

Point-in-time recovery

SQLite-backed DOs also include a safety net: any object can be reverted to the state it held at any moment in the past 30 days. If a buggy query corrupts data, you can roll back without having opted into the feature in advance — it's enabled by default for all SQLite-backed DOs.

Building an app with SQLite-backed Durable Objects

To see SQLite-in-DO in action, consider an airline seat-selection feature. Each flight gets its own Durable Object, and a SQL table inside that object tracks which seats passengers have claimed:

import {DurableObject} from "cloudflare:workers";

// Manages seat assignment for a flight.
//
// This is an RPC interface. The methods can be called remotely by other Workers
// running anywhere in the world. All Workers that specify same object ID
// (probably based on the flight number and date) will reach the same instance of
// FlightSeating.
export class FlightSeating extends DurableObject {
  sql = this.ctx.storage.sql;

  // Application calls this when the flight is first created to set up the seat map.
  initializeFlight(seatList) {
    this.sql.exec(`
      CREATE TABLE seats (
        seatId TEXT PRIMARY KEY,  -- e.g. "3B"
        occupant TEXT             -- null if available
      )
    `);

    for (let seat of seatList) {
      this.sql.exec(`INSERT INTO seats VALUES (?, null)`, seat);
    }
  }

  // Get a list of available seats.
  getAvailable() {
    let results = [];

    // Query returns a cursor.
    let cursor = this.sql.exec(`SELECT seatId FROM seats WHERE occupant IS NULL`);

    // Cursors are iterable.
    for (let row of cursor) {
      // Each row is an object with a property for each column.
      results.push(row.seatId);
    }

    return results;
  }

  // Assign passenger to a seat.
  assignSeat(seatId, occupant) {
    // Check that seat isn't occupied.
    let cursor = this.sql.exec(`SELECT occupant FROM seats WHERE seatId = ?`, seatId);
    let result = [...cursor][0];  // Get the first result from the cursor.
    if (!result) {
      throw new Error("No such seat: " + seatId);
    }
    if (result.occupant !== null) {
      throw new Error("Seat is occupied: " + seatId);
    }

    // If the occupant is already in a different seat, remove them.
    this.sql.exec(`UPDATE seats SET occupant = null WHERE occupant = ?`, occupant);

    // Assign the seat. Note: We don't have to worry that a concurrent request may
    // have grabbed the seat between the two queries, because the code is synchronous
    // (no `await`s) and the database is private to this Durable Object. Nothing else
    // could have changed since we checked that the seat was available earlier!
    this.sql.exec(`UPDATE seats SET occupant = ? WHERE seatId = ?`, occupant, seatId);
  }
}

This same pattern could be extended to push seat updates to connected clients over WebSockets, letting multiple users watch availability change in real time as they make their picks — though that goes beyond what we’re covering here.

Deploying a SQLite-backed object requires a small change in wrangler.toml. Instead of the usual new_classes migration, you declare your class with new_sqlite_classes:

[[migrations]]
tag = "v1"
new_sqlite_classes = ["FlightSeating"]

Critically, SQLite-backed objects still support the existing key/value-based transactional storage API. Under the hood, KV data lives in a hidden table within the SQLite database itself, so existing applications built on Durable Objects keep working when deployed on the new backend.

One important limitation: because this is an entirely new storage backend, you cannot convert an already-deployed DO class to SQLite. SQLite must be requested at initial deployment time. Migrations of existing DOs to the new backend are planned to begin in 2025.

Pricing and limits

SQLite-in-DO pricing mirrors D1, Cloudflare’s serverless SQL database: you pay for SQL queries based on rows read and written, plus SQL storage. During the beta, each object is capped at 1 GB of SQL storage; that limit rises to 10 GB on general availability. Standard DO request and duration billing applies unchanged, regardless of storage backend.

For the initial beta period, SQL query and storage charges are not enabled. SQLite-backed objects only incur requests and duration costs. SQL billing is expected to launch in the first half of 2025, with advance notice before it activates.

The documentation covers more details on working with SQLite in Durable Objects.

SQLite-in-DO vs. D1

Cloudflare Workers already offers another SQLite-backed database in D1, which is itself built on SQLite-in-DO. The practical difference comes down to how much management you want.

D1 is the more managed option, fitting the traditional cloud architecture where stateless application servers — typically Workers, but possibly external clients — talk to a separate database over the network. It ships with a pre-built HTTP API and managed observability features like query insights. Since application code and the database aren’t colocated, Workers can use Smart Placement to dynamically run your Worker in the optimal location, accounting for every service it talks to, including D1. By the end of 2024, D1 also gains automatic read replication for global low-latency access. If that managed model appeals to you, D1 is the choice.

Durable Objects demand more hands-on effort but provide more control. You must split your code between a front-end Worker that routes incoming requests and the DO itself, which executes on the same machine as its SQLite database. Deciding where each piece of logic lives requires careful thought, and you may need to build tooling that D1 provides out of the box. In return, you get full command over the setup — room to tailor the architecture to your application’s specific requirements and potentially extract better performance.

A log-shipping design replaces raw key-value puts

When Durable Objects launched in 2020, their durable storage was a simple key-value interface backed by a conventional off-the-shelf database. Regional instances of that database served Durable Objects in each data center. SQLite-backed Durable Objects use something entirely different: a new persistence layer called Storage Relay Service, or SRS, built from scratch at Cloudflare. SRS has been running D1 for over a year, and now applications can use it directly through Durable Objects.

SRS starts from an observation about storage tradeoffs:

Local disk is fast and randomly-accessible, but expensive and prone to disk failures. Object storage (like R2) is cheap and durable, but much slower than local disk and not designed for database-like access patterns. Can we get the best of both worlds by using a local disk as a cache on top of object storage?

Object storage is the wrong shape for SQLite

A SQLite database living on disk makes many small, rapid writes. Queries update individual rows without rewriting untouched parts of the file, and reads may jump to any offset. With proper indexes, a query touches only relevant pages and completes in microseconds.

Object storage is built for a different workload: you upload and download whole blobs by name, ideally blobs from hundreds of kilobytes to gigabytes. Latency runs to tens or hundreds of milliseconds. Naively copying the database file to object storage on every change would block the application; uploading only occasionally risks losing minutes of committed writes when a disk fails.

Batch WAL frames, snapshot on growth

SRS does not upload the database. It uploads a log of changes. SQLite already produces such a log: the Write-Ahead Log (WAL). SRS always runs SQLite in WAL mode, where changes land in a separate log file before being checkpointed into the main database. SRS hooks SQLite's VFS to intercept file writes and observe WAL frames — each frame being an instruction to write bytes at some offset in the database file.

Uploading each frame as its own object would be wasteful; SRS batches changes for up to 10 seconds or 16 MB, whichever comes first, and uploads the batch as a single object. To reconstruct a database, SRS downloads these change batches and replays them in order. Since replaying a long history is expensive, SRS also periodically uploads a full snapshot. It does so whenever the size of logs accumulated since the last snapshot exceeds the database size itself. That keeps both the download and storage overhead to at most twice the database size, since anything older than the latest snapshot can be deleted. The approach is inspired by Litestream, though Cloudflare's implementation differs.

Five followers give fast, durable confirmation

BLOG-2536 8

Batches reach object storage only every 10 seconds. An application cannot wait that long for write confirmation, and a machine failure in the interim would otherwise lose committed data. SRS solves this by forwarding each commit's change log to five follower machines across Cloudflare's network. The write is reported confirmed once at least three followers acknowledge receipt — that acknowledgement is what opens the Durable Object's output gate and lets it communicate with the outside world.

Followers buffer changes on local disk while awaiting further instructions. After SRS persists the change to object storage in a batch, it tells each follower, which then discards its copy. If that notification never arrives, a follower times out and uploads the change itself. So if the host machine dies, any single surviving follower will persist all confirmed writes.

The five followers live in five different physical data centers. Cloudflare's network has hundreds of sites, so finding four nearby peers is straightforward. Losing a confirmed write would require simultaneous failures across four machines in at least three buildings: the host and three of the five followers.

Followers also play a role in failover. When a host becomes unresponsive, Cloudflare cannot start a replacement instance until it is certain the old one cannot confirm further writes — otherwise the two instances could confirm contradictory state. The fallback is to contact the followers: if at least three can be reached and told to stop confirming writes for the unreachable instance, the old instance is effectively neutralized and a new one may start safely.

Point-in-time recovery falls out of the log

SQLite-backed Durable Objects can revert to any state from the last 30 days. This is a side effect of the SRS design rather than a separate mechanism. Because SRS retains a full change log, restoring to an arbitrary moment is just replaying the log from the last snapshot. The only adjustment is retention: normally a new snapshot lets SRS delete all older logs and snapshots, but SRS instead marks them for deletion 30 days later, leaving the data available for recovery in the interim.

That extra month of storage for high-write databases is a real cost, but Cloudflare's accounting says the economics work: once data has been written, holding it another month is generally cheaper than writing it in the first place, and the result is always-on disaster recovery.

Beta access

SQLite-backed Durable Objects are in beta as of today. Documentation is available on the Cloudflare developers site, with feedback channeled through the #durable-objects channel on the Developer Discord.