# How to Reproduce a Bug

> Reproduction is the skill that makes every fix possible: turn a vague report into a bug you can trigger on demand, shrink it to its essence, and tame the intermittent ones that won't show up when you're watching.


---

# How to Reproduce a Bug

A ticket lands: "App crashes sometimes." Or a teammate leans over: "It's broken, can you look?" You open the code, you stare, and nothing is obviously wrong. The dread that follows isn't really about the bug - it's the sinking feeling that you have no way to *get at* it. You can't see it happen, so you can't tell if it's fixed, so you're poking in the dark and hoping.

Here's the relief: almost every "I can't fix this" is actually "I can't reproduce this yet." Reproduction is the skill that turns a ghost story into a controllable experiment - something you can trigger whenever you want, watch closely, shrink down, and finally confirm dead. Get good at this one thing and the rest of debugging stops being scary. This guide shows you how.

## How to read this

- **Fighting a bug that won't show itself right now?** Jump to [Phase 3: When It Won't Reproduce (Heisenbugs)](03-when-it-wont-reproduce.md) - the cheat-card at the top maps each "it's intermittent" symptom to a tactic.
- **Want the skill to finally click?** Read in order. Each phase builds the one before it: why reproduction is the whole game, how to pin a bug down, then what to do when it fights back.

## The phases

1. **[Why Reproduction Is the Whole Game](01-why-reproduction-is-the-whole-game.md)** - you can't fix (or *prove* you fixed) what you can't trigger on demand. The mental model: make it happen reliably, then shrink it.
2. **[Nailing It Down](02-nailing-it-down.md)** - the four variables that actually matter (steps, environment, data, state/timing), and how to build a minimal reproduction by removing everything that isn't load-bearing. Includes "works on my machine," decoded.
3. **[When It Won't Reproduce (Heisenbugs)](03-when-it-wont-reproduce.md)** - the intermittent ones: the usual culprits (timing, uninitialized state, external dependencies, caching) and the tactics that drag them into the light.

> Deliberately deferred to follow-up guides: how to *fix* a bug once you can trigger it (see [Using a Debugger](/guides/using-a-debugger)), reading the crash output it produces (see [Reading a Stack Trace](/guides/reading-a-stack-trace)), and hunting down *which commit* introduced it (see [Bisecting a Bug](/guides/bisecting-a-bug)). This guide is only about getting the bug to happen on command.


---

# Why Reproduction Is the Whole Game

When a bug report says "it's broken sometimes," the natural urge is to dive into the code and hunt for the mistake by eye. That rarely works, and the reason reframes the whole job.

Reading code tells you what *should* happen. A bug is where what *should* happen and what *does* happen come apart. Seeing that gap means watching the program actually misbehave - which means being able to *make* it misbehave. That ability is reproduction, and it's the foundation everything else stands on.

## The mental model: a bug you can't trigger is a rumor

**What reproduction actually is.** A recipe: a specific, repeatable sequence - these steps, on this setup, with this data - that makes the bug appear every time you follow it. Not "it broke once"; "it breaks *whenever I do this*."

**Why this is the whole game.** A bug you can trigger on demand stops being scary - it's now a science experiment. Run it, change one thing, run it again, watch what moves. Add logging and *see the log fire*. Step through it in a debugger knowing the bug is coming. Without a reproduction you're reasoning about something you've never observed - which is why a bug you can't trigger isn't really a bug yet. It's a rumor.

```text
   A rumor                          An experiment
   ───────                          ─────────────
   "it crashes sometimes"           do A, then B, then C → it crashes, every time

   - can't watch it happen          - watch it happen on demand
   - can't tell what triggers it    - change one input, see what moves
   - can't prove a fix works        - run the recipe again → no crash = fixed
```

💡 **Key point.** "I can't fix this bug" almost always means "I can't reproduce this bug *yet*." Solve the reproduction and the fix usually follows fast - which is why early effort belongs in reproduction, not code-reading.

## The other half: reproduction is how you *verify* the fix

People skip this part: reproduction isn't only for *finding* the bug - it's the only reliable way to know you *fixed* it.

The trap: you can't reliably trigger the bug, so you change something suspicious and it doesn't show up. Fixed, or just didn't happen this time? You can't tell. Ship that "fix," the bug returns next week, and you've burned trust for nothing.

A solid reproduction closes that loop:

```mermaid
flowchart TD
  r1["Run the recipe<br/>bug appears"] --> c["Make your change"]
  c --> r2["Run the recipe<br/>bug is gone"]
  r2 --> u["Undo your change"]
  u --> r3["Run the recipe<br/>bug returns → it was YOUR change"]
```

*What just happened:* steps 1 and 3 prove the bug is real and your change removed it. Undoing the fix and watching the bug return proves it was *your change* that did the work, not coincidence. That round trip is the difference between "I think this is fixed" and "I know this is fixed."

⚠️ **Gotcha.** "It didn't happen when I tried it" is not "it's fixed" - especially for anything intermittent. If you couldn't make the bug happen *before* your change either, you've tested nothing; that's an experiment with no control. Get a reliable reproduction first, confirm the bug, then change code. Phase 3 covers earning that reliability when a bug won't cooperate.

## The two moves, in order: trigger, then shrink

Every reproduction effort is the same two moves, in this order.

**Move 1 - make it happen reliably.** Get from "it broke once, somewhere" to "I can make it break right now, on purpose." This is the hard part and the payoff part. Even a clumsy, slow reproduction - "log in as this exact user, click through eight screens, upload this file" - beats nothing, because now you can *watch*.

**Move 2 - shrink it.** Once it triggers reliably, start removing things: drop steps that don't matter, cut data to the smallest input that still breaks, strip out uninvolved parts of the system. Each removal that *doesn't* stop the bug proves that thing innocent - what's left when nothing more can go points straight at the cause.

```mermaid
flowchart LR
  vague["vague report"] -->|Move 1: TRIGGER| reliable["reliable reproduction<br/>(breaks on demand)"]
  reliable -->|Move 2: SHRINK<br/>remove what isn't needed| minimal["minimal reproduction<br/>(nothing left but the cause)"]
```

🪖 **War story.** A teammate spent a full afternoon reading a payment module line by line, certain the bug was "in there somewhere." Only when he stopped and forced himself to run a real checkout that failed did he notice the failure only hit orders over a certain amount - one observation that narrowed a thousand lines to one branch in about a minute. Reproducing first would have saved the afternoon.

**Why this saves you later.** Most "this bug is impossible" panics come from trying to fix before you can trigger. Flip the order - reproduce first, fix second - and the work stops feeling like guessing and starts feeling like turning a knob and watching a needle move. Next up: the variables you adjust to get a bug happening reliably, then pare it down.

## Recap

1. **Reproduction is a repeatable recipe** that makes the bug appear every time - not "it happened once."
2. **A bug you can't trigger is a rumor.** You can't watch it, can't experiment on it, can't reason from observation.
3. **Reproduction is also how you verify a fix:** trigger it, fix it, confirm it's gone, then undo and confirm it returns.
4. **"It didn't happen when I tried" ≠ fixed** - that's an experiment with no control.
5. **Two moves, in order: trigger, then shrink.** Make it happen reliably first; then remove everything that isn't load-bearing until only the cause is left.


---

# Nailing It Down

Your goal from Phase 1: make the bug happen *here, now, on demand*. The usual frustration - you follow what you think are the steps and nothing breaks. Worked for you, failed for them; fails on the server, not your laptop.

That gap is never magic. A program is deterministic: same conditions, same outcome, every time. If it breaks for someone and not you, *some condition differs* - and only four places can hide that difference. Find which one and you've found your reproduction.

## The four variables that decide everything

When a bug reproduces for one person and not another, the difference is in one of these four. Walk them in order - roughly most-common-first.

| Variable | The question to ask | Where it bites |
|---|---|---|
| **Steps** | What *exactly* did they do, in what order? | "I clicked save" hides three earlier clicks that set it up |
| **Environment** | What versions, OS, browser, config, flags? | The classic "works on my machine" |
| **Data** | What *specific* input/record triggered it? | An empty list, a huge file, a name with an emoji |
| **State / timing** | What was true *before*, and what raced? | A stale cache, a half-finished signup, two requests at once |

The skill is going through these deliberately instead of guessing.

### Steps: the report is always missing some

**What it is.** The precise sequence of actions leading to the bug - *all* of them, including boring setup the reporter didn't think to mention.

**Why people get this wrong.** Bug reports compress. "It crashes when I save" feels complete, but in the reporter's head they also logged in as admin, opened last week's draft, and edited a field - none of which made the ticket. Reproduce the literal words and it works fine; you're missing steps 1-3, not chasing a phantom.

**What to do.** Get the *exact* sequence - watch them do it, or have them write down every click - then reproduce it literally, same order, nothing skipped. Order matters: B then A can leave different state than A then B.

⚠️ **Gotcha.** Beware the invisible first step from *days* ago - "broken since I changed my email" means the trigger is account state set long before the click that surfaces it. If literal steps don't reproduce it, ask what's different about *your account / project / file* versus a fresh one.

### Environment: "works on my machine," decoded

**What it is.** Everything *around* your code that it depends on: language/library versions, OS, browser, environment variables, feature flags, locale, timezone, even CPU architecture. Your machine and theirs differ, and the bug may live in that gap.

📝 **Terminology.** *"Works on my machine"* isn't an excuse - it's a *diagnosis* that the bug depends on an environment difference. The job is finding *which* part.

**How to read the difference.** Compare the two environments fact by fact. Versions are the fastest first check:

```console
$ node --version
v20.11.0
```
*What just happened:* printing the exact Node.js version this machine runs. Reporter on `v18`, you on `v20`? That gap alone explains bugs where a function behaves differently, or doesn't exist, across versions. Check runtime, database, and browser versions the same way; the first mismatch is your prime suspect.

🪖 **War story.** A date-formatting bug that "only happened for some users" was timezone: it broke for anyone west of UTC because a calculation rolled to the previous day - invisible to the whole team since everyone sat in the same office and timezone. The difference was the *user's clock*, not the code.

**Why this saves you later.** Treat "works on my machine" as "an environment variable differs" and you stop arguing whether the bug is real. Containers, version pins, and shared config exist largely to shrink this gap.

### Data: the bug is often in the input, not the code

**What it is.** The specific input the code was chewing on when it broke - the exact record, file, request body, or field value.

**Why people get this wrong.** We test with tidy, typical data. Bugs love the *untidy* edges: an empty list, a `null` field, a 200 MB upload, a name with an apostrophe or emoji, a number that's exactly zero, a duplicate that shouldn't exist. "It works" usually means "it works on the nice data I tried" - the reporter hit ugly data.

**What to do.** Get the *actual* offending data, not a stand-in. If a user's record triggers it, reproduce with a copy of that record (scrub anything sensitive first). The value is often the whole bug - feed it straight to the code and you may not even need the original steps.

### State and timing: what was true before, and what raced

**What it is.** State is everything the program already held when the bug hit: cache, session, database, a half-completed workflow. Timing is *when* things happened relative to each other - two requests at once, an event firing before setup finished, a slow response.

**Why this is the slipperiest one.** Steps, environment, and data can be written down and re-created; state and timing are about the *situation* the program was in - harder to see and recreate, and why they produce the intermittent bugs Phase 3 covers. For now ask: *what was true before the steps began?* A fresh login differs from an hours-old session; an empty database from a full one.

## Now shrink it: build the minimal reproduction

Once the bug triggers reliably - even clumsily - start Move 2: remove everything not load-bearing. A **minimal reproduction** is the smallest set of steps, data, and setup that *still* triggers the bug, with nothing left to remove. Build it by subtraction: remove one thing, run the recipe, watch.

```mermaid
flowchart TD
  full["Full reproduction:<br/>9 steps, 500-row file, 3 services"] -->|remove the file, still breaks?| f1["YES → file wasn't it, drop it"]
  f1 -->|cut to 6 steps, still breaks?| f2["YES → those 3 steps were innocent"]
  f2 -->|stop service B, still breaks?| f3["YES → B wasn't involved"]
  f3 -->|cut to 1 step, still breaks?| f4["NO → put that step back; it matters"]
  f4 --> minimal["Minimal reproduction:<br/>2 steps, empty input, 1 service - still breaks"]
```

*What just happened:* each removal where the bug *still* happened proved that thing innocent, so out it went. The one removal that made the bug *stop* meant you'd cut something essential, so back it went. What survives is the irreducible core, pointing almost directly at the cause - a nine-step bug might shrink to "call this function with an empty list."

💡 **Key point.** Shrinking is *diagnosis in disguise*. Every successful removal eliminates a suspect. Once nothing more can go, what survives *is* a description of where the bug lives - often a finished bug report and half a fix at once.

⚠️ **Gotcha.** Change *one thing at a time*. Delete three steps and swap the data set in one go, and if the bug vanishes you won't know which change did it - you've lost your reproduction. Slow, single steps feel tedious but keep every result meaningful.

## Recap

1. **Four variables decide whether a bug reproduces:** steps, environment, input data, and state/timing. One of these differs when it reproduces for one person and not another.
2. **Reports are always missing steps** - get the literal, complete sequence, and watch for setup that happened earlier.
3. **"Works on my machine" is a diagnosis,** not an excuse: an environment difference. Compare versions, OS, browser, config, locale fact by fact.
4. **The bug is often in the data** - reproduce with the actual offending input, not a tidy stand-in.
5. **State and timing** are the slippery ones; they make bugs intermittent (next phase).
6. **Shrink by subtraction, one change at a time.** A minimal reproduction is the smallest recipe that still breaks - and doubles as a diagnosis.

Watch it animated: [reproducing a bug](/explainers/ReproSteps.dc.html)


---

# When It Won't Reproduce (Heisenbugs)

You've matched the steps, environment, data, and state from Phase 2, and the bug *still* shows up only one time in ten. Worse, the moment you attach a debugger or add a print statement, it stops happening. This is the bug that makes people say "haunted."

It isn't. An intermittent bug just has a **hidden input you haven't pinned yet** - a condition varying behind your back, invisible in the four variables you can see. The program is still deterministic; you just don't control all its inputs yet. This phase finds and clamps that last one.

📝 **Terminology.** A *heisenbug* is a bug that changes or vanishes when observed - named for the physics idea that observation disturbs what it measures. Usual reason: your observation tool (a debugger pause, a log line, extra code) changes the *timing* the bug depended on. The name is a clue to the cause.

## Cheat-card: symptom → likely culprit → tactic

You're probably here mid-hunt, so the map comes first, explanations underneath.

| Symptom | Likely culprit | First tactic to try |
|---|---|---|
| Vanishes under a debugger / when you add logging | **Timing / race condition** | Force the timing (add a deliberate delay), or log to a buffer, not the console |
| Fails the *first* time, works after; or only on a cold start | **Uninitialized state** | Reproduce from a truly fresh start every run; stop reusing setup |
| Fails at random intervals unrelated to your actions | **External dependency** (network, API, disk) | Pin or fake the dependency; make it fail on demand |
| Works for you, then breaks "for no reason" later | **Caching / stale state** | Clear every cache; reproduce with caching turned off |
| Different result on different runs of the *same* input | **Randomness or the clock** | Pin the random seed; freeze the clock |

## Culprit 1: timing and race conditions

**What it is.** A *race condition*: two things happen at once - two requests, two threads, an event and its handler - and the bug appears only when they finish in a particular order. Usually the "right" order wins; once in a while it flips, and it breaks. Hidden input: *which one won the race*, decided by microscopic timing you don't control.

**Why observing it makes it vanish.** Classic heisenbug: a debugger pause or log line slows one side, changing who wins, so the bug stops appearing. You haven't fixed it - you've nudged the timing to the lucky side.

**The tactic: force the timing instead of fighting it.** If the bug needs B before A, *make* B land first - a deliberate delay on A turns the rare order into the guaranteed one:

```console
$ npm test -- races.test.js
  user signup
    ✓ creates the account (41 ms)
    ✗ sends welcome email after account exists (12 ms)

  Expected: account to exist when email step runs
  Received: account not found
```
*What just happened:* a small forced delay on account creation made the email step run *before* the account finished saving, turning a one-in-ten flake into a failure every run - reproducible, and showing exactly what the email step wrongly assumed was ready.

⚠️ **Gotcha.** Don't reach for the debugger first on a suspected race - it's the one tool that hides this class of bug. Prefer logging that barely changes timing (buffer in memory, dump it *after*, rather than printing mid-race), or force the order as above.

## Culprit 2: uninitialized or leftover state

**What it is.** Some value the code assumed was set up wasn't - a variable only initialized on the second pass, a config loading a beat late, a field empty until something fills it. Shows on the *first* run, hides after, since by the second run the state exists.

**Why it's intermittent.** Depends entirely on what ran *before*. Run cold and it breaks; run again in the same session and the earlier run already set things up, so it passes. Hidden input: how fresh is the starting state.

**The tactic: always start cold.** Reproduce from a genuinely clean slate every time - fresh process, fresh session, cleared storage, freshly seeded database. If a bug only reproduces on the *first* run, that *is* the diagnosis: something's being set up by a run that shouldn't have to.

## Culprit 3: external dependencies

**What it is.** Your code talks to something outside itself - a network call, a third-party API, the filesystem, another service. Those things have moods: slow sometimes, timing out, returning an error or odd shape now and then. When *they* misbehave, *your* bug appears, and since that's intermittent, so is yours.

**Why it's hard to reproduce.** You don't control it, so you can't make it fail on command - it cooperates all morning, then fails at 2pm for thirty seconds, exactly when you're not looking.

**The tactic: take control of the dependency.** Replace the real thing with a fake or stub returning whatever you need - an error, a timeout, an empty response, garbage. Now the rare failure is a switch you flip:

```console
$ FAKE_PAYMENTS=timeout npm run dev
[payments] using FAKE client (mode: timeout)
[checkout] calling payments...
[checkout] ERROR: payment request timed out after 30s
[checkout] uncaught: cannot read property 'id' of undefined
```
*What just happened:* swapping the payment service for a fake set to always time out turned a random timeout into an on-command one, surfacing the real bug: the code assumed a response came back and crashed reading `.id` from `undefined` when one didn't. Flaky external failure, now a reliable reproduction.

## Culprit 4: caching and stale state

**What it is.** A cache stores a previous result to avoid recomputing it. The bug appears when the cached value is *stale* - out of date with reality - so the code acts on old information: "works," then mysteriously breaks (cache went stale), or breaks then heals (cache expired and refreshed).

**Why it's confusing.** The trigger isn't your latest action - it's the *gap* between when something was cached and when it was read, invisible in your steps and so feels random.

**The tactic: remove caching from the picture.** Reproduce with every cache cleared or disabled - app, browser, CDN or proxy, build caches. If the bug disappears with caching off, you've found it: something's serving stale data.

## Culprit 5: randomness and the clock

**What it is.** Code using random numbers, or reading the current date/time, has a different input every run *by design*. A bug triggering only on certain values - a specific random draw, the last day of a month, a leap year, midnight - looks random because the input genuinely is.

**The tactic: make the "random" input fixed.** Pin the random generator to a fixed *seed* for the same sequence every run, and freeze the clock. Once both are fixed, a "random" bug becomes repeatable:

```console
$ SEED=42 FAKE_NOW="2024-02-29T23:59:59Z" npm test -- billing.test.js
  monthly billing
    ✗ rolls over to next month at midnight (8 ms)

  Expected next bill date: 2024-03-01
  Received:                 2024-03-29
```
*What just happened:* freezing "now" to the last second of a leap day and pinning the random seed makes the test run identically every time. With the date locked, the month-rollover bug - which only bit on certain dates and was therefore "intermittent" - fails every run. A frozen clock turned a calendar ghost into a plain, repeatable failure.

## Putting it together

The thread through all five: an intermittent bug has an input you aren't yet controlling, and the fix is finding it and clamping it. Timing, freshness of state, an outside service, a cache, a random draw or a clock - each varies behind your back. Pin it, and the bug becomes an ordinary, triggerable one - back to Phase 1: trigger it, fix it, confirm it's gone.

💡 **Key point.** An irreproducible bug isn't magic or haunted. It has a hidden input you haven't pinned down yet. Your job with a heisenbug is to *find that input and take control of it* - then it's just a bug.

## Recap

1. **Intermittent = a hidden input you don't yet control.** The program is still deterministic; something varies out of sight.
2. **A heisenbug vanishes when observed** because your observation (a debugger pause, a log line) changes the *timing* it depended on.
3. **The five usual culprits:** timing/races, uninitialized state, external dependencies, caching, and randomness/the clock - see the cheat-card up top.
4. **The core tactics:** force the timing, always start cold, fake the dependency so it fails on command, disable caching, pin the seed and freeze the clock.
5. **Once the hidden input is pinned,** the bug becomes reliably triggerable - back to trigger → fix → verify.

Once you can trigger it on demand, the next skills pick up where this leaves off: [Reading a Stack Trace](/guides/reading-a-stack-trace) to decode the crash, [Using a Debugger](/guides/using-a-debugger) to watch it run, and [Bisecting a Bug](/guides/bisecting-a-bug) to find the commit that introduced it.
