The Minimal Reproduction
Once the failure reproduces, remove everything that is not needed for it to keep failing — the UI, the real provider, the other tables, the framework — until what remains is small enough that the cause has nowhere to hide.
The situation, the reflex, and why it stalls
Every lesson starts where being stuck starts: someone has a problem, and the first move that comes to mind feels like progress.
The bug reproduces inside the whole store, with the UI, the API, the provider and the database all involved. How do you find out which of them actually matters?
The double-click bug reproduces reliably, but reproducing it means running the frontend, the backend, a test-mode provider and the database, and the failure could be in any of them. Every hypothesis costs a full end-to-end run to test, and the stack trace goes through three layers of framework.
Debug it where it reproduces. The whole system is already running and failing; adding breakpoints and log lines to it feels like the direct route.
The full system produces too many observations. Every request touches middleware, ORM, serialisation and the provider SDK; the interesting fact is one line among thousands and there is no way to tell which.
- The full system produces too many observations. Every request touches middleware, ORM, serialisation and the provider SDK; the interesting fact is one line among thousands and there is no way to tell which.
- Each experiment is slow. A hypothesis about the database write takes a full checkout to test, so fewer hypotheses get tested, and the ones that do are the ones that are easy to try rather than the ones that distinguish.
- Components that are not involved keep getting blamed. The provider is in the path, so it is suspected, and an afternoon goes on its dashboard for a bug that reproduces with the provider replaced by a stub.
- The failure cannot be shared. "Run the whole store and double-click" is not something a colleague, a library maintainer or a test runner can do; a reproduction nobody else can run is understood by nobody else.
The move
Precisely enough to apply it to a problem you have never seen — not a slogan.
- Reduce the reproduction by removing components and checking that the failure survives each removal. Every component whose removal leaves the failure intact was not the cause and is no longer in the way; every removal that makes the failure disappear names a component that is necessary to it.
- Remove in order of cost and suspicion: the expensive, external and least-suspected pieces first, because removing them speeds up every later experiment, and because when one turns out to be necessary that is a strong result.
- Keep going until the reproduction is a single function, a single query or a single request with its input — something that fails in isolation, runs in seconds, and can be handed to someone else. At that size the cause is usually visible by reading.
- The minimal reproduction is also the smallest test that will guard the fix, and it doubles as the artefact you attach to a bug report against a library or a provider.
What to remove first
The removal order matters because each removal speeds up all the later ones and because a component that turns out to be necessary is a finding. The default order removes the expensive and least-suspected pieces first. The alternative applies when the evidence already points somewhere.
- 1Replace the browser with two direct requests from a script
because The UI is slow to drive and rarely the cause of a duplicated database row; removing it makes every later run a one-liner.
- 2Replace the payment provider with a stub that preserves the response shape and the delay
because It is external, slow, has side effects, and is the component most often blamed without evidence; a stub with the same timing keeps the condition the recipe named.
- 3Shrink the data — one product, an empty database
because Smaller state means the eventual cause is visible in fewer rows.
- 4Bypass the router and call the handler; then bypass the handler and call the repository
because Each layer removed that keeps the failure is a layer the cause is not in; the first layer whose removal loses it is where the cause lives.
- 5Write what remains as a test that fails on its own
because It guards the fix and can be handed to anyone.
The reproduction as a tree of removable parts
The full reproduction decomposes into the components it touches, and each leaf carries the observation that tells you whether it is necessary. The tree is worth drawing once, because it turns "run the whole thing again" into a checklist with a result per item.
- ├Client side— produces the two overlapping requests
- └Browser and checkout pagetestable Two concurrent POSTs from a script produce the same two rows — the page is not needed.
- └Overlap in timetestable Sequential requests produce one order; overlapping ones produce two — the overlap is necessary.
- ├Our backend— where the order is created
- └Router and middlewaretestable Calling the handler directly twice concurrently still produces two rows — not needed.
- └Handlertestable Calling the repository function directly twice concurrently still produces two rows — not needed.
- └Repository inserttestable Two concurrent calls each read "no order for cart", each insert, both succeed — this is where the failure lives.
- ├Outside the backend— suspected, and in the path
- └Payment providertestable A stub with the same delay reproduces the failure — the provider is not needed.
- └Database schematestable With a unique constraint on cart id, the second insert fails and one order remains — the schema is necessary to the bug and to the fix.
Two leaves turned out necessary: the overlap in time and the absence of a constraint. Together they are the cause; everything else was scenery.
The minimal case, as code
What survives reduction fits on a screen. It is written as a test because it will be kept, and it names the invariant it protects — one order per cart — rather than the symptom the customer saw.
1test('two overlapping checkouts for one cart create one order', async () => {2 const cart = await seedCart({ items: [{ productId: 'p1', qty: 1 }] })3 4 // The overlap is the condition the recipe recorded; without it the bug hides.5 await Promise.all([6 createOrderForCart(cart.id),7 createOrderForCart(cart.id),8 ]).catch(() => { /* one of them is allowed to fail */ })9 10 const orders = await db.orders.where({ cartId: cart.id })11 expect(orders).toHaveLength(1)12})Nothing here mentions the UI, the provider or "Payment failed". The test fails against the current schema and passes with a unique constraint — and would fail again if someone removed it, which is the point.
How to do it
Most important first.
- Start from the recipe from Reproduce It First and list the components it involves: browser, API, handler, ORM, database, provider SDK, provider.
- Replace the most expensive component with the simplest stand-in that preserves the interface — the provider with a stub that always succeeds, the browser with two direct HTTP calls — and re-run. Failure survives: that component is not the cause. Failure gone: it is necessary, keep it and note why.
- Remove data next: a cart with one item instead of five, a fresh database instead of a copy of production. Each reduction that keeps the failure makes the eventual cause smaller.
- Remove code paths: call the handler directly instead of through the router, the repository function instead of the handler. When the failure appears at the smallest layer that still exhibits it, you have found where it lives (Going One Layer Deeper).
- Write the result as a test or a script that fails on its own. If the cause is in a library, this is the bug report; if it is in your code, this is the regression test.
Worked on a concrete problem
The move has to produce something. This is what it produced.
- Double-click bug, full stack. Replace the browser with two concurrent POSTs from a script: still fails. Replace the provider with a stub that returns success after a short delay: still fails — so the provider is irrelevant, and the delay is not. Remove the router and call the pay handler twice concurrently in a test: still fails. Remove the handler and call
createOrderForCart(cartId)twice concurrently: still fails. Minimal: one repository function, two concurrent calls, two rows. The cause is now a question about the database — there is nothing that prevents two orders for one cart, and both inserts succeed (Invariants Under Concurrency). - A removal that made the failure disappear: replacing the stubbed delay with an immediate return made the two calls serialise in practice and the bug vanish. That is a result, not a dead end: the failure needs the two calls to overlap in time, which is exactly the condition the recipe recorded and a fact the fix must address.
- The minimal test as it was written: begin two transactions, each reads "no order for cart 42", each inserts one, both commit. It fails against the schema as it stands and passes once a unique constraint on
orders.cart_idexists — and the constraint, not the disabled button, is the fix.
How you know it worked
What now exists that did not before, and what question you can now ask.
- The reproduction runs in seconds, involves one or two components, and can be sent to a colleague as a file.
- For every removed component you can say "not the cause, because the failure survived without it".
- The remaining pieces are few enough that the cause is visible by reading them.
- A test exists that fails now and will pass when the fix is right — and would fail again if the fix were reverted.
The questions you can now ask
The field this whole domain exists for. After this lesson, these are the questions to put to an unfamiliar problem.
- ?Which components does the reproduction involve, and which is the most expensive one I could replace with a stand-in?
- ?Did the failure survive that removal — and if it disappeared, what does that say about what the failure needs?
- ?What is the smallest layer at which this still fails, and can I call it directly?
- ?Can the reproduction be a file that fails on its own, that I could send to someone who has never seen the store?
What can go wrong
- Reducing a bug whose cause is already known. If the stack trace names the line, the minimal reproduction is the fix's test and nothing more; do not spend a day removing components for sport.
- Removing a component and changing its behaviour at the same time. A stub that returns success instantly instead of after a delay is two changes; when the failure disappears you do not know which one mattered.
- Stopping too early because the remaining reproduction is "small enough" — still three services and a real database — when one more removal would have exposed the cause.
- Reducing so far that the reproduction no longer exhibits the *same* failure: a different exception with the same wording is a different bug.
- Reduction is work that produces no fix. Each removal is a re-run, and for a bug that needs the whole system — a genuine integration failure between two real components — reduction bottoms out with two components and a lesson about which two.
- Stubs preserve the interface and lose the behaviour. A stubbed provider cannot exhibit the provider's real timing, rate limits or partial responses, so a bug that depends on them will vanish under reduction and mislead (Treating External Systems as What They Are).
- The minimal reproduction can become an artefact valued for itself — a test suite of tiny reductions no longer connected to any real path through the system.
- "Minimal means small code." Minimal means fewest components and conditions necessary for the same failure. A reproduction that needs a real database and two transactions is minimal if removing either loses the bug.
- "Removing a component proves it is bug-free." It proves it is not necessary for this failure. The provider stub says nothing about whether the provider integration has other bugs.
- "Reduction is only for reporting library bugs." It is how you find where any bug lives; the report is a by-product. Most reductions end inside your own code.
Where this applies
Problem-solving advice is stated as universal far more often than it is. These labels say what each method is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view.
- GENERALThe reduction loop — remove, re-run, keep or restore — is the same for a failing build, a rendering glitch, a slow query and a race; only the components being removed differ.
- STAGE-SPECIFICOn a greenfield store with few components there is little to remove and the move is quick; in a large existing system with many layers, the reduction is most of the debugging and the stand-ins may need to be built first.
- ILLUSTRATIVEThe stubbed provider, the concurrent repository calls and the missing unique constraint are invented to show the reduction producing a cause; no real schema is described.
Where the depth lives
This domain asks the question and hands the answer off by name.
- — A minimal reproduction attached to a library issue is the difference between a maintainer reproducing it in minutes and closing it as "needs more info"; the same artefact serves both.