Backend Systems · Session and State

Concurrent Mutations and Versioned Session Records

Why whole-record writes lose updates when a browser issues requests in parallel, and how compare-and-set, fencing tokens, and delta operations keep the record coherent.

A session record is a small piece of mutable state read and written by every request that touches it, and it is written by clients that do not coordinate with each other. Two browser tabs, a page load firing a dozen fetches, a mobile app retrying after a timeout, and a background token refresh all act on the same key within milliseconds. Whether that produces a coherent record depends entirely on how the store write is expressed.

The default expression — load the record, change a field, store the whole thing — is a lost update generator. It succeeds often enough in development that the failure surfaces only under concurrency, and then as dropped cart items, reverted preferences, or a session that keeps changing its own permissions.

Where whole-record writes fail

Consider two requests from one client. Request A reads the record at version 7 and sets a “last activity” field. Request B reads the same version, sets a “theme” field, and writes at version 8. Request A then writes its copy, which was derived from version 7 and does not contain B’s theme change. The store now holds A’s view: last activity updated, theme reverted. Nothing reported an error, and no lock was violated.

The mechanics are identical across Redis SET, Memcached set, a SQL UPDATE, and an HTTP PUT. The difference is what each store offers to prevent it. SQL gives transactions and UPDATE ... WHERE version = ? with a row count of zero signalling a conflict. Memcached gives gets/cas. Redis gives WATCH/MULTI/EXEC on a single node, or a Lua script that performs the read, compare, and write atomically. A store that offers none of these — a plain file, a SET-only API — forces the design into a different shape.

Optimistic concurrency is the general answer: the read returns the version, the write includes the expected version, and the write fails if it no longer matches. The failure is not an error condition to surface; it is a signal to re-read and re-apply. The re-apply step is where the design has to know something the first attempt did not: whether the operation is idempotent, commutative, or neither.

// applyTheme applies one field change under optimistic concurrency.
// A conflict means another request wrote between our read and our write.
for attempt := 0; attempt < 3; attempt++ {
    rec, ver, err := store.Get(ctx, key)
    if err != nil {
        return err
    }
    rec.Theme = theme
    if ok, err := store.PutIfVersion(ctx, key, rec, ver); err != nil {
        return err
    } else if ok {
        return nil
    }
}
return ErrConflict

Three attempts, not an unbounded loop, because a hot session under contention can retry indefinitely and each retry costs a round trip. A bounded retry that returns a conflict is a better outcome than a handler that spins until the request times out.

Delta operations avoid the conflict entirely

Where the field can be expressed as an adjustment rather than a value, the conflict disappears. A view counter incremented by HINCRBY or a SQL UPDATE ... SET n = n + 1 is atomic at the store, and two concurrent increments both take effect. Timestamps that only move forward can be written with a conditional “keep the greater” operation.

Most session fields are not like that. A theme is a value, not a delta. The general technique for non-commutative changes is to store each independent field under its own key, so that concurrent writes touch different keys and the store’s per-key atomicity is enough. That trades one read for several, and it breaks down as soon as a change must be applied to a consistent snapshot of several fields — a permission check that reads role and tenant together cannot tolerate half an update.

Per-field keys also lose the ability to express invariants across the record cheaply. “The session is valid only if the tenant matches the user’s current membership” spans fields, and enforcing it requires reading them all together and preventing concurrent modification during the check.

Locks, leases, and the stale holder

Pessimistic locking serializes the whole read-modify-write. The client acquires a lock keyed by session identifier, performs its work, and releases. The attraction is that complex multi-field logic becomes simple: no retry, no conflict handling, the critical section just runs.

The problem is the lock’s liveness, not its mutual exclusion. A client that acquires a lock and then stalls — garbage collection pause, network partition, a paused container — holds it while doing nothing, and every other request for that session blocks until the lease expires. Lease expiry is the fix and also the new problem: the lease can expire while the original holder is still alive and about to write. The write then arrives after a new holder has taken the lock, and both believe they were exclusive.

Fencing tokens solve this by making the store reject the late write. Each lock acquisition returns a monotonically increasing number, and every write carries the number from the acquisition that authorized it. The store records the highest number it has seen for the key and rejects anything lower. The stalled holder’s write is refused even though it is unaware it lost the lock. This requires the store to enforce the comparison, which means either a conditional write with a version check or a purpose-built primitive; a lock service that only hands out leases without fencing leaves the hazard in place.

The cost of a lock is one extra round trip per critical section plus the latency of contention. For session updates that occur at page-load rates, that cost lands on the common path. Optimistic concurrency pays only when a conflict actually occurs, which is why it is the better default for low-contention per-user records and locks are reserved for operations that cannot be retried safely.

Idempotency is not optional with retries

Any design that retries has to ask what happens when the first attempt actually succeeded and the acknowledgement was lost. A retry of “set theme to dark” is harmless. A retry of “append a line item to the cart” duplicates the item. A retry of “increment the rotation generation” skips a number and invalidates tokens that should still be valid.

The standard construction is a client-generated request key stored alongside the effect: the write is attempted only if the key has not been seen, and the key is recorded in the same atomic operation as the effect. For session mutations the key can live in the record with a bounded set of recent keys, since only the last few seconds of retries need deduplication. Where the record is written under compare-and-set, the recent-key set travels with it and the deduplication is a comparison inside the retried write rather than a separate lookup.

The rotation race in detail

Rotation is the mutation with the most consequences, and it is where versioning earns its place. Two concurrent requests both detect that the session is due for rotation and both create new records. If both succeed, the client receives two Set-Cookie headers or two responses with different values, and one identifier becomes an orphan holding a live authenticated record — an unreferenced credential nobody will revoke.

A rotation enforced through the record’s version prevents the duplicate: the first writer bumps the version, the second’s compare fails, and the second request either re-reads the new state and adopts it or returns the existing new identifier to the client. The rule that follows is that rotation must be a compare-and-set on the session that is being replaced, and the operation must be idempotent from the client’s perspective — presenting the same old identifier twice should yield the same new identifier rather than a second one.

The same pattern covers logout. Two concurrent logouts, one of which deletes the record and one of which reads it as still present, must not leave a resurrected record. Deleting by key is naturally idempotent; re-creating a record on a miss is not, and any code path that writes a fresh record when the store returns nothing will undo a deletion that happened between the read and the write.

Client-side serialization and the illusion of order

Some of the concurrency originates in the client, and the client is also the cheapest place to remove it. A browser can keep one in-flight request per session-mutating operation and queue the rest, using a coordination primitive such as BroadcastChannel to share the queue between tabs of the same origin. A mobile client can serialize on its own request queue. The server cannot depend on either happening, but a client that serializes eliminates the conflict before it reaches the store and makes the retry paths rare rather than routine.

Server-side ordering, where it is needed, comes from a version that only moves forward. Requests carrying a stale version are rejected or, if the change is commutative, merged. That is the same rule used in How a Session Identifier Binds Requests to Server State at the pointer level and in Read-Your-Writes Across Replicated Session Stores at the replica level: a monotonic number that both sides can compare without consulting a clock.

Choosing the discipline per field

Not every field needs the same treatment, and applying compare-and-set to all of them adds retry paths where none are needed. A field that is a monotonic timestamp, a counter, or a set of flags with independent bits can be written with a delta or a bitwise operation and never conflicts. A field that is a coherent group — the identity block, the permission scope, the rotation generation — should be written as a unit under a version check. A field that is purely informational and last-write-wins by nature, such as a UI preference, can be written bluntly and the rare lost update ignored.

The record therefore tends to split into a small versioned core and a set of independently keyed satellites. The core carries the invariants; the satellites carry preferences and counters. The boundary between them is the design decision, and it should follow the authorization logic: anything a permission check reads belongs in the core and is only written under compare-and-set.

References

  1. Dynamo: Amazon's Highly Available Key-value Store — DeCandia et al., SOSP 2007
  2. The Chubby Lock Service for Loosely-Coupled Distributed Systems — Burrows, OSDI 2006
  3. Designing Data-Intensive Applications — Martin Kleppmann, O'Reilly 2017