Skip to main content

Cursors: mutating the immutable

· 3 min read
Mike Anderson
Hacker, Convex Foundation
Claude
AI Assistant, Anthropic

Immutable data is wonderful right up until your application needs to do something. Convex 0.8.2 ships Lattice Cursors — a thin, atomic, mutable layer over immutable lattice values that gives applications a familiar "read it, change it, write it back" model without giving up the guarantees of the immutable values underneath.

The gap being bridged

Everything in the Data Lattice is an immutable, content-addressed value: perfect for verification, replication and structural sharing; awkward for an application that just wants to bump a counter. The classic answer is to hand-roll an atomic reference and a compare-and-swap loop around your root value — and then hand-roll it again, slightly differently, in the next component. Cursors make that pattern a first-class, well-specified thing.

A root cursor is a mutable pointer to an immutable value, with the full atomic toolkit: get, set, compareAndSet, updateAndGet, accumulateAndGet and friends. When you "update" through a cursor, the value isn't touched — the cursor atomically swings to a new immutable value, and anyone still holding the old one keeps a perfectly consistent snapshot.

Path cursors: zoom in, stay atomic

Real state is nested. A path cursor targets a value deep inside the root — ["users" alice-key :balance] — while delegating every operation atomically to its parent. Updating a leaf rebuilds the path above it (structural sharing keeps that cheap) in one atomic step at the root, so two threads updating different branches never tear each other's writes.

Lattice-aware path cursors handle one subtle case well: writing through a missing intermediate doesn't invent a generic hash map — the intermediate is created from the lattice's own zero() value, so it has the type the lattice expects. The type system of your replicated data survives your write path.

Fork, modify, sync

The pattern we expect to see everywhere is fork–modify–sync. Fork a working copy of a cursor; make a batch of updates in isolation; then sync() back. Here's the part that matters: sync doesn't compare-and-swap and hope. It merges your working copy into the parent using lattice merge semantics — so it always succeeds, with no retry loop, even if the parent moved while you worked. Two concurrent forks don't race to overwrite each other; they converge, exactly as lattice values are supposed to.

(The plain merge() compare-and-set variant exists too, for when you genuinely want "my transaction or nothing". The point is that the choice is explicit.)

Why this matters beyond tidiness

DLFS already speaks this model, and every lattice application we're building follows it: hold a cursor to your root, navigate with paths, batch with forks, converge with sync. Getting the concurrency story right once, in one specified layer, means application authors stop reinventing it — and stop reinventing it subtly wrong.

The full specification is CAD035. It's a small abstraction, deliberately boring on the surface — and it's fast becoming the standard way every stateful thing on the lattice is written.