ab-testing4 min read

Five milliseconds too soon: the listener that died before its event

A SPA checkout removed our tracking listener 5ms before dispatching the one event it existed to catch. The fix wasn't attaching earlier — it was learning to detach later.

Timeline showing a listener detached at teardown, the navigation event dispatched 5ms later into silence, and a grace window bridging the gap

We had a tracking event that refused to exist. The experiment was fine, the selector was fine, the analytics pipeline was fine — and the event count in the dashboard was zero, every day, with sessions we could watch doing the thing we were supposedly counting.

The thing being counted was a form submit inside a checkout that behaves like a single-page app: step transitions swap the DOM server-side and announce themselves afterwards with a custom navigation event. The form never submits in the classic sense — no submit event, no navigation, nothing bubbling up through the document. If you want to know the user moved from shipping to payment, the framework’s own “navigation succeeded” announcement is the only truthful signal there is.

Why this event was special to begin with

Our tracking layer is declarative: an experiment ships a list of things to watch, and a registry turns that list into listeners. Almost everything rides on body-level event delegation — one listener at the top, elements matched as events bubble past. Delegation is the right default for experiment work because the DOM underneath you is churn: elements get re-rendered, replaced, morphed, and a delegated listener doesn’t care.

But a custom event dispatched on window never bubbles past body. It starts and ends above it. So the registry grew a second mode: listeners that bind to the window itself, with a filter on the event’s payload so an experiment can say “only count the navigation that lands on the payment step”. Fine. Mode built, listener bound, event still zero.

The autopsy

Our tracer stamps every line with a relative timestamp from page start, which is the only reason this bug was findable at all. Two lines, from one session:

[+14 382ms] event-registry: window listener detached (step teardown)
[+14 387ms] framework: navigation event dispatched

Five milliseconds. The checkout tears its step down, our cleanup reacts like a good citizen and unbinds the listener — and then the framework dispatches the navigation event describing what just happened. The listener died five milliseconds before the only event it existed to catch. Every single time. It’s not even a race in the probabilistic sense; the ordering is deterministic. Teardown first, announcement second. Our cleanup was simply too polite.

The fixes that don’t work

Attach earlier? The listener was attached plenty early. Attachment wasn’t the problem; the removal was.

Don’t remove at all? Now every step transition leaves another listener behind. After four steps of checkout you’re counting some events four times, and the memory profile of a long session gets steadily worse. “Never clean up” is not a tracking strategy, it’s a leak with a dashboard.

Delay the whole teardown? The teardown isn’t ours. The framework owns the step lifecycle; we’re guests in it. Holding its DOM hostage until we’ve heard our event is exactly the kind of fighting-the-host behaviour that experiment code must never do.

Detach lazily, re-attach idempotently

The shape that works: when the registry is asked to remove a window listener, it doesn’t. It schedules the removal, and the listener keeps working through a short grace window — a second, in our case, which is geological time next to a five-millisecond gap. If nothing happens before the grace expires, the listener is removed for real and nothing leaked.

The second half matters just as much: when the next step initialises and the same listener registers again, the registry recognises it and flushes the pending removal instead of stacking a second binding. Re-registration is idempotent. Between the two rules, there is no moment where the listener is gone and no moment where it exists twice — which is the entire requirement, stated backwards.

The event count went from zero to exactly the number of sessions doing the thing. And because this class of bug is invisible until it isn’t, the behaviour is pinned by a spec suite now: teardown-then-dispatch ordering, grace expiry, double-registration, the lot. This is precisely the kind of code where “it works, I watched it” decays silently the next time anyone touches the lifecycle.

The general lesson

SPA teardown and the events describing that teardown are ordered against you more often than you’d think. “Remove listeners on teardown” quietly assumes the teardown is silent — that once the DOM goes, nothing more will be said about it. But plenty of frameworks narrate their own navigation after cleaning up, and a listener that leaves politely misses the story.

So: if the event you care about is the framework’s own announcement of a lifecycle change, your cleanup must outlive that lifecycle by a beat. Make removal lazy, make re-registration idempotent, and log with timestamps precise enough to see a five-millisecond gap — because without that last one, you’ll never know which of the first two you needed.

Happy tracking 🙂