The Timer Nobody Could Stop
Sentry’s tracing had two countdown timers and one place to store their handles. One timer wrote its handle into the other’s variable, and the orphaned timer went off early — quietly cutting page-load traces short and dropping everything that happened afterwards. The fix was a single variable name.

Syed Suhail Ahmed
Oct 11, 20263 min read

Some bugs crash. This one just quietly left things out.
Sentry was recording page loads with the end cut off. Spans that genuinely happened never showed up in the trace — not delayed, not marked as errors. Simply absent.
What the code is trying to do
When Sentry traces a page load, it opens a container called an idle span. Everything that happens during the load — network calls, renders, clicks — goes inside it.
The hard question is when to close it. A page load has no “done” event. So Sentry guesses, using two separate countdowns:
The idle timer — “nothing new has started for a while, so we’re probably finished. Close up.”
The child timer — “one single thing has been running far too long. Stop waiting for it.”
Two different jobs. Two independent countdowns.
When you start a countdown in JavaScript with setTimeout, you get back a handle — think of it as a coat-check ticket. It’s the only way to cancel that countdown later. Lose the ticket and the timer keeps running, and nothing can stop it.
The bug
The child timer put its ticket in the idle timer’s pocket:
function _restartChildSpanTimeout() {
_cancelChildSpanTimeout();
_idleTimeoutID = setTimeout(/* ... */); // ← wrong variable
}That line should have written to _childSpanTimeoutID. Two things follow.
First, _childSpanTimeoutID is never assigned anywhere. It’s declared, and it’s read by the cancel function — but nothing ever writes to it. So _cancelChildSpanTimeout() was dead code at both of the places that called it.
Second, and this is the damaging part: on the page-load path these two functions run back to back. The idle timer starts and stores its ticket. The child timer immediately overwrites that same variable with its ticket — without cancelling the idle timer first.
The idle timer is still running. Its ticket is gone. Nothing in the program can stop it anymore.
Why that cut traces short
Later, when the in-flight work finished, the code did what it was supposed to: “cancel the idle timer.” But the variable now held the child timer’s ticket, so it cancelled the wrong one.
Meanwhile the orphaned idle timer was still counting down — from the moment the page load started, not from when the last piece of work finished. It fired early and closed the trace.
Anything that started in the window after that point got dropped. If you were looking at the trace wondering why a request was missing, it wasn’t missing. It was outside a container that had already been sealed.
Why no test caught it
There are two callers of the broken function, and only one of them is affected.
The other caller runs “cancel the idle timer” immediately before it. So by the time the overwrite happens, the idle timer is already stopped — there’s nothing to orphan. That path works perfectly.
Only the page-load path arms the idle timer and then immediately clobbers its ticket. One of two callers. The tests covered the safe one.
The fix
- _idleTimeoutID = setTimeout(() => {
+ _childSpanTimeoutID = setTimeout(() => {One variable name. I added a regression test that advances fake clocks to prove the trace now stays open until the last child actually ends — it fails on the old code and passes on the new.
I filed the issue first with a runnable failing test rather than opening a surprise pull request. A Sentry engineer confirmed it the same day — _childSpanTimeoutID genuinely was never used anywhere — and the fix was merged.
What I’d take from it
A variable that is read but never written is a bug your linter won’t find. Linters flag unused variables. They don’t flag a variable that something reads, because as far as the tooling is concerned, it’s being used. It just always holds
undefined.“This code is dead” is a symptom, not a finding. The dead cancel function wasn’t tidy-up to delete. It was the shape of the real bug.
Two things that share one variable aren’t independent. The code was written as if these timers were separate. One shared assignment quietly welded them together.
Subscribe for new contributions
Get an email when I publish a new open-source write-up — how I approached the issue, the code, and lessons learned. No spam.