Debugging 2026-05-25

"Works on My Machine": a Field Manual for Unreproducible Bugs

When you cannot reproduce a bug from production, the standard playbook — stare at the report, poke at staging, reply “could not reproduce, closing” — isn’t debugging. It’s a coin flip with extra steps. The bug is real. It happened on real hardware to a real user, and the reason you can’t summon it locally is that some condition of that user’s world isn’t present in yours. The entire craft of handling unreproducible bugs is identifying which condition, and the fastest way to do that is to stop trying to recreate the bug and start recording it where it lives.

That’s the thesis of this manual: you don’t reproduce these bugs, you record them. Reproduction is a luxury for bugs whose triggering conditions are cheap to recreate. For everything else, the goal is to instrument production well enough that the next occurrence is your reproduction — captured, replayable, inspectable.

But “instrument production” is uselessly generic advice, because unreproducible bugs are not one species. In fifteen-odd years of chasing them I’ve found they nearly always fall into one of four families, and each family has a different tell in the report and a different capture strategy that cracks it. Classify first. Then instrument for the family you’re hunting.

The four families of bug you cannot reproduce outside production

1. State-dependent: it’s their data, not your code path

The tell: the bug follows specific users. It happens to the same three accounts every time, on any device, any browser, any time of day. Everyone else is fine.

What’s actually happening: your code has an assumption about data shape that most accounts satisfy and these accounts violate. The classics: an empty collection where you assumed at least one element, a null in a field that’s “always” populated, a name with a character your regex didn’t anticipate, a user with 40,000 items in a list you designed around 40, an enum value that stopped being emitted two years ago but survives in old rows. Your staging seed data is polite and homogeneous. Production data is feral.

Capture strategy: you need the inputs, not the click sequence. Log the shape of data entering the failing component — counts, null flags, string lengths, type tags, never the values themselves if they’re sensitive. When an exception fires, attach the sanitized state that fed it; an error tracker that captures local variables or lets you attach context objects turns “TypeError in formatRow” into “TypeError in formatRow when items is empty and groupBy is set,” which is usually the whole diagnosis. Session replay contributes the click path that assembled the state. And once you’ve identified the poisonous shape, the fix’s regression test writes itself: reproduce the shape in a fixture, not the user.

2. Timing-dependent: the race you lose one time in fifty

The tell: the same user, doing apparently the same thing, hits the bug intermittently. Reports mention slowness — “it happens when the site is being laggy” — or double-clicking, or switching tabs mid-operation. Nobody can make it happen on demand, including the user.

What’s actually happening: two async operations whose completion order you implicitly assumed. Response B arrives before response A because A hit a cold cache. The user clicked submit twice, 300ms apart, and the second request read state the first was mid-mutation on. A component unmounted while its fetch was in flight. On your machine, on localhost, with a warm everything, the race resolves the same way ten thousand times in a row. On a phone, on transit wifi, the loser wins.

You will not reproduce these by clicking around. You can sometimes reproduce them by force: DevTools network throttling, artificial per-endpoint latency injection, a loop that hammers the interaction hundreds of times. Worth an hour. Not worth a day.

Capture strategy: ordering evidence. You need a timeline with every request’s start and end, every user input, and the error, on one clock. This is exactly what session replay tools that record network activity alongside interactions were built for: watch the failing session and you can see the second click land before the first response, or the responses arrive inverted. Without that, structured logs with a session ID and millisecond timestamps can rebuild the sequence, painfully. Once you can see the losing order, encoding it in a test (mock the latencies, force the inversion) makes the bug reproduce on every run — the only reliable way to prove the fix.

3. Environment-dependent: their browser is not your browser

The tell: clusters in the metadata. All reports are Safari. Or all one Android vendor. Or the bug started for a slice of users on the same date — which is when a browser version or a popular extension update rolled out, not when you deployed.

What’s actually happening: a browser API behaving differently (Safari’s storage and date-parsing quirks have earned their reputation), a content-blocker extension amputating a request your code assumed would succeed, an aggressive translation extension rewriting your DOM under your framework’s feet, a corporate proxy stripping headers, an OS-level font or locale changing layout until a button lands offscreen. Your code is fine. Its habitat isn’t.

Capture strategy: two layers. First, make sure user agent, OS, viewport, and locale ride along with every error report — they usually do by default — and actually aggregate on them: one Safari report is an anecdote, eleven of eleven being Safari is the answer. Second, record enough of the DOM to see the environment’s fingerprints: replay of a session where an extension mangled the page shows the mangled page, which no log line ever will. When a vague report says “the button doesn’t work,” the replay showing the button rendered 900px below a broken flex container ends the investigation. Then reproduce in the real environment: BrowserStack or a device shelf, with the actual extension installed. Emulation lies about exactly the quirks that cause this family.

4. History-dependent: the ghost of sessions past

The tell: the bug evaporates when the user clears their cache, reinstalls, or uses an incognito window. Support has learned this and “clear your cookies” has become the team’s reflexive first response — which fixes individuals and guarantees you never diagnose the actual bug.

What’s actually happening: state that outlives the session. A localStorage schema written by your app two versions ago and read by code that expects the new shape. A service worker serving a stale bundle against a new API. A JWT with claims from before the permissions refactor. An A/B flag assignment persisted in a cookie for an experiment you deleted. Fresh installs — which is what your dev environment is, every day — literally cannot exhibit these bugs. That’s why this family feels the most supernatural and is the most mechanical once you see it.

Capture strategy: version everything that persists, and log the version on read. A schemaVersion key in localStorage plus one log line — stored version versus expected — converts this entire family from ghost story to grep. Same for service workers: log the active SW version and the app bundle version together; mismatches are the smoking gun. For diagnosis after the fact, an error report that includes a snapshot of relevant storage keys (sanitized) tells you what the ghost looked like. And the durable fix is structural: migration-on-read for persisted schemas, and a hard rule that deleting an experiment includes deleting its cookie.

The pattern across all four

Look at the four capture strategies together and they’re one strategy: move the evidence collection to where the bug is. Data shape at the failure site. Event ordering on one timeline. Environment metadata aggregated, DOM recorded. Persistent state versioned and logged.

None of this requires exotic tooling, but it does require the tooling to be already running when the bug fires — instrumenting after the report means waiting for the next victim. That’s the argument for having always-on capture with reasonable sampling rather than debug flags you enable reactively. It’s also why the combined form factor — replay, console, network, and errors on a single timeline, which is the shape of tools like LogReplay — punches above its weight on exactly this class of bug: families 2 and 3 are diagnosed by correlation, and correlation across four separate tools with four separate clocks is where investigations go to die.

One closing opinion, since this manual promised positions: “cannot reproduce” should be banned as a ticket resolution. It’s not a finding; it’s a confession that your production visibility has a hole, filed under the bug’s name instead of your own. Classify the bug, name the missing evidence, add the capture. The second occurrence should never be as mysterious as the first.

See the bug the way your user did

LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.

Try LogReplay free