kilden_
← blog

Kafka never carries blobs: session replay as a claim check

KE
Kilden Engineering 13 May 2026 · 3 min read
on this page ▾

For a while, some of our session replays played back as minutes of black screen. The timeline insisted something had been recorded. The chunks were all in storage, checksums intact. Nothing was corrupted, nothing was lost — and the replays were still unwatchable.

The bug was in a filename. We'll get there — but first, the architecture it lived in, because the bug only makes sense once you see what replay's cargo actually looks like.

The heaviest thing on the pipeline

Session replay records the DOM and every mutation after it; a busy session produces megabytes. The rest of Kilden moves events of a few kilobytes through Kafka — replay's chunks would have turned the same brokers into a reluctant blob store, with broker memory, replication traffic and retention all sized for the biggest cargo instead of the common case.

So replay follows one hard rule inherited from the pipeline's design: Kafka never carries blobs. It carries claims about blobs.

The recorder sends heavy chunks to object storage and a tiny pointer through Kafka to the indexer, which writes the session index in ClickHouse. recorder rrweb · gzip object storage …/{recording_id}/{n}.json.gz chunks · up to 4 MB 30-day lifecycle pointer · ~200 B kafka topic replay_pointers replay-indexer clickhouse session index The brokers never see a blob. The pointer is the promise; the storage keeps the weight.

The recorder ships compressed chunks — gzip, as the SDK sent them, up to 4MB — to a dedicated ingest endpoint that writes them straight to object storage:

text · object storage layout
{project_id}/{session_id}/{recording_id}/{chunk_index}.json.gz

What goes to Kafka is a pointer of roughly 200 bytes: session, recording, project, person, chunk URL, index, byte size, time range, page URL, and whether the chunk saw an error. A consumer batch-inserts those pointers into the session index in ClickHouse — commit after insert, dead-letter queue for poisoned pointers, exactly like every other consumer in the system. The pointer topic gets Kafka's ordering and replayability; the blobs get object storage's economics; and a 30-day lifecycle rule on the bucket does retention by itself.

The filename that ate our replays

Now, that recording_id in the storage key. It wasn't there at first. Look at what happens without it:

Two page loads in one session both write chunks numbered from zero; without a recording id in the key, the second load overwrites the first, including the full snapshot. one session · 30 min page load A 0 1 2 chunk 0 = FullSnapshot — playback cannot start without it page load B · same session, index restarts 0 1 same key …/{session_id}/0.json.gz → overwrites A's FullSnapshot The fix: put recording_id in the key — every axis on which writes can repeat must be in the key.

chunk_index counts from 0 — per page load. But a session lasts as long as the user keeps coming back within 30 minutes. Visit a page, record chunks 0–4, click a link — and the new page starts writing chunk 0 again. Same session, same key prefix, same filenames. The second page load silently overwrites the first one's chunks, including chunk 0, which holds rrweb's FullSnapshot: the single chunk playback cannot start without.

Hence the black screens. The player had events — mutations, clicks, scrolls — but the snapshot they mutated from had been overwritten by a different page's opening frame. Nothing in the write path failed; every PUT succeeded. That's what made it maddening: the bug produced no errors, only silence where a replay should be.

The fix is the key: each recorder start mints a recording_id (a UUIDv7, validated at ingest because it lands in a storage path), and both the storage key and the index's sorting key include it. Two recordings of one session now coexist instead of overwriting, while redelivered pointers still dedup exactly as before. The player merges recordings by timestamp and skips any prefix it can't reproduce.

The rule worth stealing

A storage key must contain every axis on which writes can repeat. Session and index weren't enough axes — the index could reset while the session lived on, and any key where one part can reset while another survives is a collision waiting for its moment. If you take one thing from this post, audit your keys for that pattern. Ours passed review, passed tests, and shipped; the only thing that caught it was a user coming back for a second look.

$ npm install kilden
Analytics, feature flags, campaigns and session replay — one event pipeline, one SDK.

1M events/month on the free tier. Set up in about two minutes.

new posts, monthly Engineering and product notes. No spam, unsubscribe anytime.