← The Iris Lab
Post-mortemJuly 6, 2026·7 min read

We filmed a UI bug frame-by-frame to catch it lying.

A chat panel flashed when you opened it — a blink you could feel but never point at. The layout-shift meter swore nothing moved. So we recorded the bug at sixty frames a second, caught a single skeleton frame in the stack, and followed it home to a cache that wiped itself whenever a quiet poll returned an empty page. A post-mortem about instruments that lie in different ways.


A chat panel in our agent inbox flashed when you opened a conversation — a blink, maybe a tenth of a second, gone before you could put a finger on it. Every automated instrument we trusted said nothing was wrong. The layout-shift score read a clean zero. The bug was real, our tools were honest, and every one of them was looking in the wrong place. This is the story of how we filmed a UI bug at sixty frames a second to catch it in the act — and what it taught us about the gap between a bug you can measure and a bug you can only see.

The finding that matters up front: an instrument that reports "all clear" is not the same as all-clear — it is one narrow question, answered honestly. The blink was invisible to our metrics because it was the wrong kind of event, and it stayed invisible until we stopped measuring and started recording.

The complaint was a feeling

The report came in the least actionable form a bug ever takes: "it flickers when I open a thread." Not reproducible on command. Not every time. Sometimes on the first open of the day, sometimes never for an hour. The kind of report that, on a bad team, gets closed as "can't reproduce" and quietly rots.

We couldn't reproduce it reliably either — but we could reproduce the feeling, and a felt bug is still a bug. "Can't reproduce" usually means "I haven't found the right instrument yet," not "there's nothing there." So the first job wasn't to fix anything. It was to see it.

The first instrument said the bug didn't exist

The obvious tool for a flash-on-open is the layout-shift observer — the browser's built-in measure of content jumping around, the thing behind the Cumulative Layout Shift score everyone optimizes for. We wired it up, opened threads until the blink happened, and read the number.

Zero. A clean, confident 0.00.

Here is the subtlety that cost us an afternoon: a layout-shift score of zero doesn't mean nothing changed on screen. It means nothing moved sideways. CLS measures geometric instability — a box that shoves the paragraph below it down half a line, an image that loads late and elbows the text. Our blink wasn't a shift. Nothing jumped. The content briefly vanished and came back in the same place. To a layout-shift observer, an element that disappears and reappears in its exact original position is a non-event. It never moved, so the score is honestly, uselessly zero.

That's lesson one, and we'll come back to it: the metric wasn't wrong, it was answering a different question than the one we were asking. We asked "did the screen flicker?" and the instrument answered "did anything move?" — and treated the two as the same. They are not.

Film the bug

If the eye couldn't hold the frame and the metric couldn't see it, there was a third option left: record it and step through by hand. We took a burst of screenshots across a single reload — around twenty frames spanning the open, dumped to disk, then scrubbed one at a time like a film editor looking for a bad splice.

Nineteen frames showed the real conversation. One frame — a single frame, near the very start — showed the empty skeleton: the grey placeholder we paint before data arrives, the "loading…" shape a returning agent should never see because their data is already cached locally.

A screenshot burst through one reload — the bug is a single frame in the stack
Reload
frames
open≈120mssettled
Every dark cell is a frame that painted the real thread. The one pale cell is the skeleton — for a single frame the screen rendered empty, then refilled. Fast enough to feel like a flicker, too fast for the eye to name.

There it was, frozen and undeniable. The bug wasn't geometric and it wasn't a paint-timing fluke. For exactly one frame, the interface genuinely believed it had no data to show. You cannot argue with a screenshot. The felt blink was a real, single frame in which the whole panel emptied itself and then refilled — too fast to name, impossible to deny once it was sitting still in front of you.

Following the empty frame home

Now it was a normal bug, and normal bugs have addresses. The skeleton renders under one condition: the conversation store is empty. But the store wasn't empty — a returning agent's threads are cached on the device precisely so the panel paints instantly. So the real question sharpened to: why did a populated store go empty, for one frame, on open?

The trail led to the local cache. The inbox keeps a small client-side store (an IndexedDB cache) so that opening the app doesn't mean staring at a spinner — your recent conversations are already there, painted from disk, while the network catches up. That store is kept fresh by a quiet background poll: every few seconds the app asks the server "anything new?", and a reconcile step merges the fresh answer into what's on screen.

The killer was in the merge. Under a specific timing, one of those background polls came back with an empty page — zero items, a perfectly well-formed, entirely legitimate-looking response. The reconcile step did exactly what it was built to do: it reconciled the on-screen list toward the incoming truth. And the incoming truth was "nothing." So it emptied the store. The panel dutifully painted the empty state — the skeleton — for one frame, until the very next real fetch repopulated everything and the screen snapped back to normal.

The causal chain behind one blank frame
0
An empty page arrives
A quiet background poll returns a list with zero items
The cache trusts it
The reconcile loop treats “empty” as the new truth and clears every cached thread
1
One frame paints empty
The view renders the now-empty store for a single frame, then real data lands and refills it
The fix was two lines: ignore an empty background refresh instead of committing it over data you already hold, and never render a thread whose id doesn’t match the one in the address bar. Empty inputs are the classic reconcile killer.

An empty response is the most dangerous input a reconcile loop ever receives, because it looks exactly like success. It isn't an error you'd catch and skip. It isn't a timeout or a 500. It's a clean, empty, believable page that says, with total confidence, "there is nothing here" — and a naive merge believes it.

The one-line guard

The fix was small, which is usually the sign you've found the real cause and not a symptom. A background refresh that arrives empty is almost never the truth — it's a race, a transient, an edge in pagination. So the rule became: don't commit an empty background page over a store that isn't empty. If the poll says "nothing" but we're already holding conversations, keep what we have and wait for the next real answer. One guard, a handful of characters.

Then a second line, belt-and-suspenders, that turned out to matter more than the first: never render a conversation whose id doesn't match the one in the address bar. Not a fix for this bug exactly — a rendering invariant. The URL is the single source of truth for what should be on screen; if the store and the URL ever disagree, the URL wins and the store waits. A whole family of "wrong thing flashed for a frame" bugs simply cannot happen once that invariant holds.

A good fix doesn't just delete the bug you found. It deletes the shape of the bug — so the next three cousins of it never get born. The guard stops the store from being wiped by an empty page. The invariant stops a wiped-or-wrong store from ever reaching the screen at all. Two independent walls, either of which alone would have hidden the symptom; together they close the category.

What we actually learned

Three lessons, each more portable than the fix itself.

Instruments lie in different ways, so triangulate. The layout-shift observer is superb at catching jank and completely blind to a repaint-in-place. Had we trusted its zero and moved on, we'd have shipped "works as intended" over a real defect. No single measure is ground truth; it's one honest answer to one narrow question, and the art is knowing which question you actually asked.

When the metric and the eye disagree, film it. A screenshot burst stepped through frame by frame is the cheapest debugging tool almost nobody reaches for. It converts an argument about whether a bug exists into a still image you can point at. For anything intermittent and visual, recording beats reasoning.

Empty inputs are where reconcile loops go to die. Any system that merges a fresh copy of state into a live view — every cache, every sync engine, every optimistic UI — has this bug latent inside it, waiting for the day the upstream returns an empty set that means "transient," not "deleted." Treat empty as suspicious by default, especially on a background path the user didn't ask for.

That last one is why the inbox is built local-first in the first place: the whole point of a client-side cache is that the screen never goes blank between you and your work. A bug that briefly blanked it was, in a sense, the cache betraying its own reason to exist — which is exactly why it was worth a full afternoon and a roll of film to hunt down. The panel is meant to feel instant and stay solid; now it does one frame more reliably than it did before.

Put this playbook to work.

Create a workspace, paste one snippet, publish a few articles. Free to start — live before your coffee cools.

Keep reading

Real NumbersJuly 6, 2026

WhatsApp-grade images on a chat widget, with zero image servers.

Real NumbersJuly 6, 2026

Self-hosting the observability stack: the real math.