A Checkpoint Saver That Doesn't Ask for a Database

I kept hitting the same wall building LangGraph agents: the moment I wanted checkpoints to survive a restart, the official savers wanted a Postgres instance or a SQLite file sitting on a disk I had to manage myself. Fine for a long-running server. Awkward for anything that deploys as a container with no attached volume, which by then was most of what I was shipping. I already had a bucket. I didn’t want a database next to it just to remember where an agent left off.

So I built langgraph-checkpoint-objectstorage: one ObjectStorageSaver class that reads the scheme off a connection string (file://, s3://, gcs://) and picks the backend accordingly. No subclass per provider, no config object with a backend name and three sets of credentials fields. Change the string, change where checkpoints live. That constraint, one class covering all three backends, ended up shaping everything else about the package.

It also created a trap I walked into almost immediately. LangGraph’s BaseCheckpointSaver contract requires both sync and async methods (put/aput, get_tuple/aget_tuple, and so on), and my first instinct was to write both, since the async I/O libraries for S3 and GCS (s3fs, gcsfs) are genuinely async under the hood. I got about halfway through a second, sync-flavored implementation of the same key-listing logic before I noticed I was fixing the same bug twice, in two places, worded slightly differently each time. That’s the kind of drift that turns into a support ticket eighteen months later when someone finds the one method where the sync and async paths quietly disagree.

I threw out the second implementation. The business logic (key layout, filtering, ordering, idempotency) is written once, as async methods. The sync public API is a thin asyncio.run(...) wrapper around that same core, not a parallel implementation:

I/O bridge: sync and async public API both funnel into one async core, which reaches the backend through a bridge that picks native async I/O when the filesystem supports it and a thread otherwise

Underneath that core sits an I/O bridge that picks native async calls when the backend filesystem supports them, and falls back to asyncio.to_thread when it doesn’t. Local disk has no async API to speak of, so it just runs on a thread, and nobody outside the bridge can tell the difference. One source of truth per operation, not two implementations drifting apart a release at a time.

The other decision I didn’t compromise on: every checkpoint and every write becomes its own object, named by thread, namespace, and checkpoint ID, never a row updated in place.

Object key layout: thread ID, then namespace, then separate checkpoints/ and writes/ prefixes, each checkpoint and write stored as its own object keyed by ID

No read-modify-write on an existing key means two writers on different threads can never race each other, and a single put call is always a single write, not a read followed by a write with a gap in between where someone else’s write could land. It costs something: list() with a filter has to fetch every checkpoint in a thread and filter client-side, since object storage has no query engine to push a filter into. For the threads I actually run, dozens to low hundreds of checkpoints, that trade is free. I wrote it down in the README anyway, because “free for now” is exactly the kind of claim that stops being true silently.

Encryption raised a sharper version of the same question — what “wrong” should even mean here. Encrypting the payload bytes with AES-256-GCM was an afternoon’s work. Deciding what happens when someone moves an encrypted object somewhere it doesn’t belong took longer. I bound the cipher’s associated data to the object’s full storage identity — thread, namespace, checkpoint ID, task, write index. Move that object to a different thread and it doesn’t quietly decrypt under whatever key that path happens to resolve to. It just fails to decrypt at all. I’d rather a checkpoint refuse to open than open into the wrong conversation (see ADR 0004 for the full design).

The part I was most tempted to skip was testing against the real contract instead of my own assumptions about it. It would’ve been easy to write unit tests that check what I built does what I meant it to do, which mostly just confirms I’m consistent with myself. Instead I ran the saver through langgraph-checkpoint-conformance, the same contract test suite LangGraph’s own sqlite and postgres savers are held to, against all three backends: local disk, and S3 and GCS through local emulators so CI doesn’t need a cloud account to prove anything. That’s what caught the put_writes overwrite rule I’d gotten backwards on my first pass: regular channels are first-write-wins, but the control channels (ERROR/SCHEDULED/INTERRUPT/RESUME) always replace. My tests passed. The conformance suite didn’t, until I matched the official savers exactly. I’d rather find that out in CI than from someone’s production agent losing an interrupt.

Testing caught the correctness bugs. Performance didn’t get to hide behind a guess either — I built a real benchmark suite before I let myself believe anything about how fast this was, and local disk turned out to be paying a tax nobody had noticed: every sync call was spinning up a fresh event loop just to tear it down again, because the sync API is a thin wrapper around the async core underneath it. One persistent background loop per saver instance, reused across every call instead of rebuilt for each one, and the local-disk numbers moved together across every single operation, because they’d all been quietly paying the same hidden cost (see ADR 0006 for the profiling that found it).