A database mistake is rarely dramatic at first. It is usually one bad UPDATE, an application bug that overwrites valid state, or a deployment that writes data in a shape we did not expect.

With a normal SQLite database, I would think about backups before making a risky change. SQLite-backed Cloudflare Durable Objects add another useful option: point-in-time recovery, or PITR. Cloudflare keeps enough history to restore an object’s embedded SQLite database to a point within the previous 30 days.

That is especially interesting because the recovery boundary is one Durable Object. If I model one room, account, document, or workflow as one object, I can recover that object’s storage instead of treating the entire application as one database recovery event.

Here is the idea: PITR is not a replacement for good application design, but it gives destructive mistakes a much better failure story.

Start with SQLite-backed Durable Objects

PITR is available for Durable Objects using the SQLite storage backend. Cloudflare recommends SQLite for new Durable Object namespaces, and new classes are configured with new_sqlite_classes in Wrangler.

{
  "durable_objects": {
    "bindings": [
      {
        "name": "DOCUMENTS",
        "class_name": "Document"
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": ["Document"]
    }
  ]
}

Inside the object, SQL storage is available through ctx.storage.sql.

import { DurableObject } from "cloudflare:workers";

export class Document extends DurableObject {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);

    ctx.storage.sql.exec(`
      CREATE TABLE IF NOT EXISTS documents (
        id TEXT PRIMARY KEY,
        body TEXT NOT NULL,
        updated_at INTEGER NOT NULL
      )
    `);
  }
}

Each SQLite-backed Durable Object owns its own embedded database. Cloudflare currently documents a maximum of 10 GB per object on the Workers Paid plan, so I still need to choose an object key that distributes state sensibly rather than putting an entire product into one giant object.

Treat bookmarks as recovery coordinates

PITR does not ask me to manipulate SQLite WAL files or copy database files. Its API works with bookmarks.

I can request a bookmark representing the object’s current storage history:

const bookmark = await this.ctx.storage.getCurrentBookmark();

A bookmark is an opaque string. Cloudflare makes bookmarks lexically comparable, so an earlier bookmark sorts before a later one, but I would still treat the value as an API token rather than parsing meaning out of it.

This is useful before an operation that is logically risky even if the SQL itself is valid.

async replaceDocument(id: string, body: string) {
  const before = await this.ctx.storage.getCurrentBookmark();

  this.ctx.storage.sql.exec(
    `UPDATE documents
     SET body = ?, updated_at = ?
     WHERE id = ?`,
    body,
    Date.now(),
    id,
  );

  return { before };
}

I would not return recovery bookmarks to ordinary clients like this in a real API. The example only shows when the bookmark is captured. In production, I would put recovery operations behind an administrative path with authentication, authorization, and audit logging.

Recover by time when a bookmark was not saved

The nicer part is that I do not need to predict every incident and save a bookmark first. getBookmarkForTime() can locate a bookmark for approximately a requested time within the retained history.

const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const bookmark = await this.ctx.storage.getBookmarkForTime(fiveMinutesAgo);

This makes timestamps in deployment and incident logs genuinely useful. If a faulty release started corrupting one object’s state at 14:03, I can choose a time just before that event and obtain a recovery bookmark.

I would still avoid blindly restoring to “five minutes ago.” First I want to establish when the first bad write happened and whether valid writes happened afterward. A restore rewinds storage; it does not understand which writes I consider good or bad.

Schedule the restore, then restart the object

Restoring is deliberately not just another SQL statement. Cloudflare exposes onNextSessionRestoreBookmark() to configure what storage should look like the next time the Durable Object starts.

async restore(bookmark: string) {
  const undoBookmark =
    await this.ctx.storage.onNextSessionRestoreBookmark(bookmark);

  this.ctx.abort();

  return undoBookmark;
}

There is an important detail hidden in this small example. onNextSessionRestoreBookmark() returns a special bookmark for the point immediately before recovery. That means the recovery itself can be undone by restoring to that returned bookmark later.

In practice, ctx.abort() ends the current object session so it can restart with storage restored to the selected bookmark. I would therefore not design the method around successfully returning a normal HTTP response after abort(). A better operational API records the intent outside the object, triggers recovery, and verifies the result after a new session starts.

For example, an admin workflow can look like this:

identify affected object
find incident start time
resolve target bookmark
record recovery request
schedule restore
abort object session
reconnect and verify invariants

The verification step matters as much as the restore.

Remember that PITR restores the whole object database

A SQLite-backed Durable Object can use SQL tables and the key-value storage API. PITR covers the object’s embedded SQLite database, including SQL data and data written through the storage key-value API.

That is convenient, but it also means recovery is not table-specific.

Imagine an object containing both document content and billing metadata:

CREATE TABLE documents (
  id TEXT PRIMARY KEY,
  body TEXT NOT NULL
);

CREATE TABLE usage (
  day TEXT PRIMARY KEY,
  writes INTEGER NOT NULL
);

If I restore because documents was corrupted, usage is rewound too. Any valid storage changes after the chosen bookmark disappear from the recovered state.

This affects how I model Durable Objects. State that has different recovery requirements may deserve different objects or an external system of record.

For example, I would be cautious about placing a collaborative document and an irreversible payment ledger in the same recovery boundary merely because both belong to the same user.

Do not confuse recovery with selective rollback

Suppose a bug runs these writes:

10:00 good edit A
10:01 good edit B
10:02 bad edit X
10:03 good edit C
10:04 incident detected

Restoring to 10:01 removes X, but it also removes C.

PITR is therefore excellent for “return this object’s database to how it looked then.” It is not automatically the best tool for “remove only this one logical mutation.”

For domains where later valid changes must survive, I prefer application-level history as well:

CREATE TABLE document_versions (
  document_id TEXT NOT NULL,
  version INTEGER NOT NULL,
  body TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  PRIMARY KEY (document_id, version)
);

An append-only version table can repair one document semantically. PITR can recover the entire object after a broader storage incident. The two techniques solve different problems and work well together.

Make dangerous writes observable

A recovery feature is much less useful when I cannot answer “when did the corruption begin?”

For mutations that can affect a lot of state, I like structured logs containing the object identifier, operation name, deployment version, request ID, and affected row count.

console.log(JSON.stringify({
  event: "document_replace",
  documentId: id,
  deployment: this.env.VERSION,
  updatedAt: Date.now(),
}));

For administrative bulk operations, I would go further and capture a current bookmark in the operator audit record before starting.

const before = await this.ctx.storage.getCurrentBookmark();

await writeAuditRecord({
  action: "normalize-document-schema",
  objectName,
  recoveryBookmark: before,
});

runMigration();

That turns the bookmark into part of the change-management process rather than something I only discover during an incident.

The audit record should live outside the object being recovered. Otherwise the restore can rewind the record that tells me what happened.

Keep recovery control outside normal application traffic

I would never expose an endpoint such as this directly:

// Avoid this design.
if (url.pathname === "/restore") {
  const bookmark = url.searchParams.get("bookmark")!;
  await this.ctx.storage.onNextSessionRestoreBookmark(bookmark);
  this.ctx.abort();
}

Even if the bookmark itself is not a secret, restoration is a privileged destructive operation. A caller who can rewind state can potentially resurrect deleted records, undo authorization changes, or remove newer data.

A safer design uses a separate administrative service that:

  1. authenticates the operator;
  2. checks authorization for the exact object;
  3. records the requested target and reason;
  4. requires confirmation for high-impact restores;
  5. invokes a narrowly scoped recovery method;
  6. verifies state after restart;
  7. records the result and the undo bookmark.

To be fair, that is more ceremony than calling one API method. During an incident, though, this ceremony is exactly what prevents a recovery attempt from becoming a second incident.

Verify invariants after restart

A successful restore API call does not prove that the application is healthy. I want to check domain invariants after the object comes back.

For a collaborative document object, that might mean:

const row = this.ctx.storage.sql.exec(`
  SELECT
    COUNT(*) AS count,
    MIN(version) AS min_version,
    MAX(version) AS max_version
  FROM document_versions
`).one();

if (row.count > 0 && row.min_version !== 1) {
  throw new Error("document history does not start at version 1");
}

Other useful checks include foreign-key relationships, expected singleton rows, monotonic sequence values, and whether derived state can still be rebuilt.

I also want a small functional check through the normal application path. Storage can be internally consistent while the restored schema is incompatible with the currently deployed code.

That leads to another practical issue: code and data are recovered independently.

Coordinate PITR with deployments

PITR rewinds the object’s storage. It does not automatically roll back the Worker code that is currently running.

Suppose version 12 migrates a table and starts writing a new column. If I restore storage to a point created under version 11 while version 12 code remains deployed, the current code must be able to handle the older schema or migrate it safely again.

I try to make schema initialization idempotent:

this.ctx.storage.sql.exec(`
  CREATE TABLE IF NOT EXISTS settings (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL
  )
`);

For more complex migrations, I keep an explicit schema version and test recovery across deployment boundaries. A destructive ALTER or data rewrite deserves more care than a CREATE TABLE IF NOT EXISTS statement.

The incident runbook should therefore answer two separate questions:

  • Which storage point should this object return to?
  • Which application version can safely read and mutate that storage state?

Solving only the first question can leave the object technically restored but operationally broken.

Test the recovery procedure before needing it

PITR is not supported in local development because local Durable Objects do not keep the durable change log required for recovery. That means a real recovery exercise needs a deployed non-production environment.

I would test a sequence like this:

1. Create known state A.
2. Capture its bookmark and timestamp.
3. Write state B.
4. Verify B is visible.
5. Restore to A.
6. Reconnect after the object restarts.
7. Verify A is visible and B is gone.
8. Restore using the undo bookmark.
9. Verify B returns.

This tests more than the storage API. It tests authentication, object addressing, restart behavior, observability, schema compatibility, and the operator’s ability to verify the outcome.

A recovery feature I have never exercised is still mostly a theory.

Know what PITR does not solve

The documented recovery window is 30 days, so PITR is not long-term archival. If I have legal, compliance, analytics, or permanent-history requirements, I still need another durable copy or event history designed for that purpose.

PITR also operates per Durable Object. If one business transaction spans several objects plus an external database, restoring one object does not atomically rewind all of those systems.

And because an individual Durable Object is single-threaded and acts as its own consistency boundary, I should resist using recovery as an excuse to pack unrelated state into one object. Horizontal scale still comes from distributing data across many objects.

Recovery changes how I think about risky mutations

The part I like about SQLite-backed Durable Objects is not simply that SQL is available at the edge. The more interesting feature is that the storage model gives each object a clear consistency and recovery boundary.

For ordinary bugs, I still prefer constraints, transactions, idempotent handlers, and application-level history. Those prevent or selectively repair bad state without rewinding unrelated work.

PITR is the safety net for the cases where those controls are not enough. I can resolve a bookmark from an incident time, restore the entire object’s SQL and key-value state, restart it, verify invariants, and even retain an undo point for the recovery itself.

In the end, the API is small. The engineering work is deciding what belongs inside one recovery boundary and building an operational path that makes restoring state deliberate, observable, and testable.