diagnosing-before-fixing

Use when a bug, test failure, crash, or any behavior that doesn't match what the code should do needs a fix and none has been proposed, especially when the obvious quick fix is tempting or an earlier fix didn't hold. Also use when a failure has been blamed on the network, the CI runner, a flaky dependency, or this machine and that attribution has never been tested, when one measurement's own parts contradict each other, when a parameter appears to have no effect at all, especially when the result is byte-identical rather than merely close, when a behavior is called absent because the log line that would mark it never appeared, or when a defect has only ever been seen through a preview, a local server, or another stand-in for the real path. Covers the reproduce-hypothesize-test loop, tracing a symptom back to where it originates rather than where it surfaced, escalating from repeated failed fixes to questioning the architecture, and validating a fix at every layer the bad data passes through.

Overview

A fix aimed at where a failure was noticed and a fix aimed at where it was caused are usually different changes — only the second one stays fixed. The loop is mechanical: reproduce the failure on demand, form one hypothesis specific enough to be wrong, design the smallest test that could prove it wrong, run it, and act on what comes back. Guessing under pressure feels faster; a thrash of unverified fixes is not — the systematic pass wins on wall-clock time, not just on correctness.

When to use

  • A test fails, a process crashes, or output stops matching what the code is supposed to do, and no cause has been named yet.
  • A fix is about to be proposed for where the error surfaced rather than for why it happened.
  • Under time pressure, when the fastest-looking change is also the one nobody has verified explains anything.
  • A first fix didn't hold, or a second one didn't either.
  • The failure crosses more than one component — client and service, build and sign step, app and database — and it isn't yet clear which one is actually at fault.
  • Not for: a failure that is harming users, data, or money right now. The loop below reproduces before it fixes, and during an outage every minute spent reproducing is spent on the people affected — responding-to-incidents inverts the order, and hands back here once they are safe.
  • Not for: the specific technique once you're already inside the loop — isolating which change introduced a regression (bisecting-failures), interpreting a crash or trace (reading-stack-traces), a failure tied to timing or interleaving (debugging-concurrency), memory or handles that grow without bound (finding-resource-leaks), or a failure that only sometimes happens (flaky-test-triage). This skill owns the loop; those five own the specialized ground inside it.

The loop

  1. Reproduce. Trigger it on demand, or measure a rate if it won't reproduce every time. Nothing to trigger yet at all — that's reproducing-bugs. Intermittent, and the failing thing is specifically a test — that's flaky-test-triage.
  2. Hypothesize. State one specific cause: "X is happening because Y." If you can't say what result would prove it wrong, it isn't a hypothesis yet — it's a hunch with better grammar.
  3. Test. Design the smallest change or probe that could falsify the hypothesis. One variable. Run it.
  4. Act. Confirmed — fix at the source. Refuted — form a new hypothesis with what was just learned. Don't bolt a second untested change onto the first.

Repeat from step 2 on a refuted hypothesis. Repeat from step 1 if the investigation reveals the reproduction was never actually triggering the reported failure.

Specialized terrain inside the loop

This skill is the loop, not a technique for reading a stack trace or narrowing a commit range. Once a hypothesis narrows the failure to a specific shape, the move usually already has a name:

The hypothesis narrows to...Reach for
Which commit, config change, or dependency bump introduced itbisecting-failures
What a crash, exception, panic, or trace is actually sayingreading-stack-traces
Timing, thread or task interleaving, or load-dependent behaviordebugging-concurrency
Memory, descriptors, or connections that grow without boundfinding-resource-leaks
A failure that passes on rerun or only shows up in CI or under parallel loadflaky-test-triage

Each of those skills still ends in a hypothesis confirmed or refuted — come back here and feed the result into step 4.

Investigate first

A hypothesis formed before this step is a guess wearing a hypothesis's clothes.

DoWhy
Read the full error and trace, not just the first lineThe message usually names the exact invariant that broke
Confirm the reproduction is real — same steps, same result, every time or at the measured rateA hypothesis tested against a reproduction that wasn't actually reproducing the reported bug explains nothing
Check what changed — diff, recent commits, new dependency, config, environmentMost bugs are introduced, not discovered. A diff too large to read by eye is what bisecting-failures is for
Find a working comparison and list every difference, however small"That can't matter" is usually said about exactly the difference that does

When the failure crosses more than one component — a pipeline stage, a client and a service, a build feeding a signing step — don't guess which layer is broken. Add a log at each boundary: what entered the component, what left it, what the environment actually was there. Run it once. The evidence, not a hunch about which layer you understand best, tells you where to look next.

Trace backward to the source

A bug rarely originates where it's noticed. The error surfaces three calls downstream from the bad value — a file created in the wrong directory, a request rejected two services later, a null where something upstream should have set it — and fixing where it surfaced only patches the symptom while the actual defect waits to resurface down a different path.

Trace backward instead: what directly caused this? What called that, and with what value? Keep asking one level up until you reach the place the bad value was actually produced, then fix there — not at any of the points along the way. When reading the call chain by eye stalls, a stack trace captured immediately before the operation, not after it fails, usually unsticks it.

reference/root-cause-tracing.md has the full technique, including how to instrument a trace when the chain can't be followed by reading alone.

Prove it before you build it

Testing a hypothesis means changing the smallest thing that isolates the variable, running it, and reading the result before touching anything else. A second untested change stacked on top of the first means neither result — pass or fail — tells you anything about either change alone.

When the test has to wait on something asynchronous, wait for the actual condition, not a guessed duration:

before — guesses at timing:
  sleep 50ms
  read result
  expect result is set

after — waits for the condition:
  wait until result is set, timeout 5s
  read result
  expect result is set

A fixed sleep before checking a result is a race wearing a test's clothes — it passes on a fast, idle machine and fails the moment either stops being true. Poll for the condition instead, with a timeout and an error that names what never became true. The one legitimate fixed delay is testing timing behavior itself, a debounce interval or a throttle window — and even then, wait for the trigger condition first and comment the number so it reads as derived rather than guessed.

reference/condition-based-waiting.md has the polling pattern and the ways it commonly gets implemented wrong.

Fix at the source, then close every layer

Write the failing test first — the smallest reproduction, automated if a framework is available, a one-off script if it isn't. A fix nobody watched fail against a red test is running on faith; writing-the-failing-test-first covers writing it properly and confirming-before-claiming-done covers confirming the result before calling it done.

Make one change, addressing the cause the trace pointed at — not a bundle of adjacent cleanups, and not a second fix stacked on an unconfirmed first one. If it doesn't hold, that's a refuted hypothesis: return to investigating with what was just learned, rather than reaching for fix attempt number two before understanding why number one failed.

One validation point feels sufficient the day it's added and stops being sufficient the day a different call path, a test mock, or a routine refactor bypasses it. Once the trace shows where bad data enters and everywhere it does damage on the way through, validate at every layer in between. reference/defense-in-depth.md has the four layers and why each one catches what the others miss.

A retry is an experiment only if something differed

Re-running a failing operation feels like gathering evidence. It only is evidence if something changed between the runs — the input, the environment, the code, the credentials, the time. When nothing differed, the second run is a re-observation of the first and the third is a re-observation of the second, and the cost is paid three times for one fact.

Byte-identical output across attempts is itself a finding, and a useful one: it says the failure is deterministic, so the cause is structural rather than transient. That is the moment to stop attempting and start reading — the error text word by word, the documented behaviour of whatever emitted it, the platform rule it may be quietly stating. A message that repeats to the character is very often a system telling you a fact about itself that no local change can alter.

This sits in deliberate tension with the reproduction step above, and the tension is the point. Identical results are exactly what you want when confirming a reproduction is real, and exactly what should stop you when you are attempting a fix or an operation. Same observation, opposite meaning, and the question that separates them is always what differed between those two runs? If the honest answer is "nothing", no information was produced.

Note the cost, too. The three-failed-fixes signal below is real but expensive — it charges three fixes before it fires. This one is available immediately, before any fix has been written, and it costs a re-read.

A factor that never varied has not been ruled out

Something gets dismissed early — the machine, the time of day, which of two paths the input took, which backend served the request — because every run so far shows it was the same. That is not elimination. A factor that never varies cannot explain variance, and that is a different statement from it not being the cause: the runs that would have shown its effect were never taken.

The tell is cheap and always available: name the run where that factor was different. If you cannot, it was observed, not tested.

The mistake is expensive because it is quiet. The dismissal is made once, early, in half a sentence, and everything after it is built on top — so the same wrong assumption survives every later hypothesis, including the sound ones.

It also survives the evidence that should kill it. A harness can be recording the factor on every single run, and a value that never changes reads as reassurance rather than as an untested variable — the data that would overturn the assumption is right there, being read as confirmation of it. When several individually-reasonable fixes have all failed, the thing to look for is not another hypothesis but the factor every one of them held fixed.

Vary it on purpose. That is the whole fix, and it is usually cheaper than the fix you were about to attempt — a routing flag, a different host, forcing the other branch. When the un-varied factor is which commit, that is what bisecting-failures is for. If varying it is genuinely impossible, say so and record it as an assumption rather than a finding.

Several anomalies at once describe the apparatus, not the hypothesis

The loop's Act step takes the result and reads it as confirmation or refutation. That reading assumes something nobody checked: that the experiment ran. A test built to settle a hypothesis is new code, and new code has its own bugs — so a bad result has two possible authors, the world or the instrument, and they call for opposite responses. Believing the instrument retires a hypothesis that may be correct, and retires it silently, because a refuted hypothesis leaves nothing behind pointing at itself.

The discriminator is available before any analysis, and the counting is the whole of it: count the ways the result is anomalous, and ask whether one wrong idea explains all of them. A wrong hypothesis is a single mistaken belief about the cause, so it usually surfaces as a single deviation — the number moved the wrong way, or failed to move. A run that simultaneously never finishes a third of its attempts, triples its latency, and contains none of the category the change was built to select for is three unrelated failures, and no one wrong idea about the cause produces three unrelated failures. That is the signature of an apparatus that is broken, and the honest verdict on it is no result, not refuted.

One anomaly is evidence and several are a symptom, which inverts the usual instinct that a worse result is a stronger signal. When the count is high, stop reading the verdict and go read the new code — the hypothesis has not been tested yet, whatever the numbers say.

A reading that contradicts itself is about the instrument

The section above splits on how many deviations there are, and the one below on how exact the single one is. There is a third discriminator between them, cheaper than both and available before any analysis at all: does the reading agree with itself?

A measurement usually reports more than one thing — a count and a listing, a verdict and the sample it was reached from, a summary line and the rows above it, a pass and the log it emitted. When two parts of one reading cannot both be true, no fact about the system under test explains it, because the system was never asked a question with a contradictory answer. Something in the apparatus produced it: a detector that ran against different input than the reporter, a counter incremented on a path the filter no longer reaches, a summary computed before the last stage wrote, a format change that made the matcher stop matching while the reporter carried on reporting zero. A run that reports zero failures while displaying one is not a surprising result about failures. It is a broken failure detector, and its verdict on everything else is worth nothing.

The reason to check this first is that the rule above points the other way. A single deviation, it says, usually means a wrong hypothesis — and a self-contradicting reading arrives as exactly one deviation, so the ordinary move is to believe it and start revising the hypothesis. Internal incoherence overrides the count: it is not a weak signal that the apparatus might be broken, it is a proof that it is, and no amount of interesting content elsewhere in the output survives it.

So before reading a result for what it says about the system, read it against itself, and prefer measurements that give you the material to do that — emit the sample beside the count, the failing case beside the verdict, the rows beside the total. A measurement that reports only a single number cannot be checked this way at all, which is a reason to want a second number rather than a reason to trust the one.

An exact null result indicts the plumbing, not the parameter

The section above handles several anomalies at once. This is its single-anomaly complement, and the discriminator is not how many deviations there are but how exact the one is.

A knob is changed and the output does not move. The conclusion offers itself immediately — the knob does not matter — and it is usually right. But there are two ways for an output not to move, and they are distinguishable:

What you seeWhat it says
The output changed a little, or within noiseThe input arrived and its effect is weak. The finding is about the parameter
The output is byte-identical to the baselineThe input very likely never arrived. The finding is about everything between the knob and the code that reads it

Perfect identity is the stronger signal and reads as the weaker one, because "no difference at all" sounds like a more decisive version of "no difference". It is a different claim. A real input with a real effect almost never lands on exactly the same bytes; something that was overwritten, defaulted, normalised, cached, or dropped on the way in produces exactly that, every time.

So when a null result is exact, do not retire the hypothesis. Trace the value from where you set it to where it is read, and find out whether it survived — a log at the point of use settles it in one run. The usual culprits are the harness itself, a default applied after your assignment, and a config layer that silently ignores unknown keys.

That list is missing the culprit that survives longest, because it is the only one that keeps producing an exact null after you have correctly diagnosed and fixed one cause. Nothing says a staged path holds one blocker. Where two or three sit in series — a value dropped on the way in, and a downstream stage that would have discarded it anyway — fixing the first changes nothing observable, and the byte-identical result you get back is indistinguishable from the result you would have got if your hypothesis had been wrong. So a correct fix reads as a refutation, and the usual response is to retire a hypothesis that had just been confirmed. drawing-boundaries owns the design-time half of this, where several individually correct rules compose to admit nothing and no one owns the composition; here you are downstream of that system rather than designing it.

The move is to stop prescribing one fix at a time. Enumerate every stage between the input and the observed output before changing anything, and say for each whether it is known to pass the value, known to drop it, or unexamined — then fix all the unexamined ones together and take one reading. Otherwise each attempt buys a single bit of information at the cost of a full cycle, and the bit it buys is ambiguous.

One harness culprit is easy to miss because it freezes the reading rather than the input. A tool that writes its result to a fixed path can fail, exit 0, and leave the previous run's file in place, so every later reading is the first one replayed and "nothing changed" is exactly what you will see. Remove the output before each run, or check its timestamp against the run's start, and treat a missing or unchanged file as no reading rather than as the same reading again.

A symptom read off a screen has passed through a renderer first

A value on a dashboard, a badge, a status line or a report is not a reading of the system behind it. It is that reading after one more stage of code — the one that chose what to print when the value was missing, stale, or failed to load — and that stage sits between you and every hypothesis you can form about the stages behind it. modeling-errors explains why its author reaches for a default that looks like health; this is the same collapse seen from the other end, by whoever is debugging it. A ?? 0, or its equivalent in any language, over a fetch whose failure is swallowed prints the identical 0 for not read yet, the read threw, and genuinely zero.

So before tracing backward from a displayed value, open the code that displays it and read what it does with absence. Two tells make the display the suspect rather than the source: the value is its type's zero or empty case, and a second, independent record of the same quantity — a log line, a database row, another screen — disagrees with it. In one observed case, several hours went on five backend hypotheses about a tile reading 0 while the same process had logged the true count of four at every startup; the renderer took two minutes once anyone looked at it. Reading the second record first is nearly always cheaper than the first backend hypothesis.

A guard that refuses to run has already measured something

The instrument can also refuse to take a reading, and a refusal arrives looking like an obstacle rather than like data. A preflight check, a version gate, a compatibility guard, a health probe that declines to proceed — the immediate reflex is to find the flag that suppresses it, because the goal was to run the thing and the guard is what is between here and running it.

That reflex discards the most informative result available. A guard exists because somebody knew something about the target that whoever is bypassing it does not, and it fires precisely when that knowledge applies. An installer that notices it predates its host by dozens of releases and exits cleanly is not being fussy: forced past, it would have produced a confident, silent no-op against an integration point that no longer exists. The refusal was the accurate statement about fitness, and it was free.

So read the guard before overriding it — what condition does it test, and is that condition true here? A guard that turns out to be wrong is worth suppressing and worth fixing, and a suppression flag with no answer to that question behind it converts a clean failure into a confusing success. The urge to bypass is strongest exactly when the guard is most load-bearing, because that is when it is most in the way.

An absent log line is evidence only if the emitter was armed

Every section above reads a measurement you took. This one reads the one you did not get. You go looking for the signature of a behaviour in a log across the window it should have run in, find nothing, and conclude the behaviour did not happen — which is a conclusion about the code drawn from a fact about the logger.

What makes it slip past is that nothing here is broken. The emitter is correct, the line it writes is accurate, and the behaviour it describes really did occur. The line simply sits behind a condition this particular run never met. A memoised or cached path emits while it is building the answer and is silent every time it serves one, so the log is loudest when the system is coldest and quietest under exactly the steady-state traffic you are usually investigating. A level-gated line needs a verbosity nobody enabled here. A sampled line fires on one request in a hundred. A once-per-process line fired before you started reading.

Note which way that cuts. A broken instrument tends to announce itself; this one produces a clean, quiet, entirely plausible negative, and the confidence that follows is proportional to how carefully you searched — a thorough sweep of a window the emitter was never armed in feels like strong evidence and is none.

The tell is the same shape as the un-varied factor's, and just as cheap: name the run in which that line would have been written. If you cannot, you have measured the logger, not the system.

Two moves settle it, and both are cheaper than the fix the negative was about to justify. Read the emitter in the source before reading its absence — the condition guarding it is right there, and one look says whether this run could ever have tripped it. Then, if it could not, arm it: clear the cache, restart the process, use a key nothing has warmed, raise the level, force the cold path. A log line you have watched appear once tells you what its silence is worth for the rest of the session.

A transport-shaped failure in a credential flow indicts the clock, not the value

A short-lived, rotating credential — a pairing code, a CSRF token, a presigned URL, a nonce — gets fetched once at the top of a multi-step flow and spent several round trips later: focus a field, clear it, type into it, take a confirmation screenshot, then submit. Each step is individually cheap and its own round trip, and the sum routinely outlives the credential's own window — which is short by design. The failure that reaches you is not "invalid credential." It is whatever the transport layer says about a value nothing downstream ever got to examine: a closed connection, a reset socket, an exception that never got far enough to say what request it was. That symptom sends the search into the network stack first, which is rarely where the fix belongs.

The tell: a validation-shaped failure ("rejected", "invalid", "denied") means the credential arrived and was judged. A transport-shaped failure (connection closed, socket reset, a raw exception below the layer that would know what it was carrying) partway through a flow built on a short-lived credential means the clock is the first thing to check, not the credential.

The fix matches the diagnosis: fetch the credential in the same call or script that consumes it, with zero intermediate round trips — no confirmation screenshot, no separate tool call — between fetch and submit. Where the flow cannot be collapsed that tightly, check the credential's remaining lifetime immediately before the step that spends it and re-fetch rather than gamble that the window is still open.

"It's the network." "The runner is slow today." "That dependency is flaky." "It's this machine." These are hypotheses like any other, with one property no other hypothesis has: accepting one ends the investigation instead of directing it. Every other explanation says where to look next. This class says there is nowhere to look — which makes it the cheapest thing to believe and the most expensive thing to be wrong about.

It sticks because it is available rather than supported. Something unrelated was slow in the same hour, or the failure is intermittent, or the timing simply fits — and the story is plausible enough that nothing feels unresolved, so nobody goes looking for the contradiction. Then it propagates. An attribution written into one note gets repeated in the next one, and after enough repetitions it reads as an established fact with a history behind it rather than as a guess nobody ever tested.

What separates this class from every other hypothesis is that it is also the cheapest one in the whole investigation to falsify. Whatever it blames has a liveness check measured in seconds: one request against the endpoint, one ls-remote against the host that is supposedly unreachable, one SELECT 1, one look at the runner's own status page. So name the probe that would refute the attribution, and run it before writing the attribution down — then run it again before repeating it. A known issue nobody has re-tested is a guess with tenure.

The same reflex misfires in the other direction, and there it produces a wrong report rather than a stopped search. When the thing has only ever been seen through something local — a preview server, a viewer, an emulator, a staging copy — the environment is not the lazy hypothesis to be resisted. It is the first one, because it sits between you and the object and it is the part that is not the deliverable. A local server that sends no charset leaves the client to guess one, and every em dash of a perfectly valid document comes back as mojibake; a base path, a compression setting, a stale cache, a viewer substituting a font it does not have, a terminal's own encoding each do the same for their own class of defect. All of them look like faults in the artifact, because presentation is precisely what they change. Reported as seen, that is a defect filed against something that does not have it — and then fixed there, in an artifact that was already correct. The remedy is this section's own, pointed the other way: before writing down anything observed only through a local stand-in, obtain the same artifact through the path its real consumers use, and compare. A difference between the two is a fact about the stand-in, measured rather than attributed. confirming-before-claiming-done's Evidence does not travel to another environment makes the matching argument for a green result; a red one inherits that limit unchanged.

Comparing against the real consumers' path sorts faults of presentation, and it can mis-sort faults of execution. Where the stand-in differs by not running something, or running it late — a hidden browser pane that gets no animation frames or scroll events, an app the operating system has suspended, a host that sleeps — the real path runs it and passes, so every failure seen only in the stand-in looks like the stand-in's, including the ones that are not. One question sorts them: can production be in the stand-in's state? A scroll position never saved because the test's script, standing in for a user, scrolled a hidden pane and no scroll event fired belongs to the stand-in: no user scrolls a view they cannot see. A restore queued for the next animation frame belongs to the code: users leave views in the background too, and there it waits exactly as long as it did in the test. Fix that one by removing the dependence on what the host withheld, not by bringing the test to the foreground. finishing-what-you-started covers what the same kind of host does to a pass: a clean zero from an instrument that never ran.

None of that softens the main rule, and none of it contradicts the section below. An environmental cause really is a legitimate place for an investigation to end, and documenting one honestly is the right outcome when it is true. Documenting it is simply not the same act as establishing it, and the gap between the two is usually one command that takes under a second. Nor is this bounding-autonomous-work's cheapest experiment wearing different clothes: that one tests a claim about your own capabilities before handing work back to a person, this one tests a claim about the world before you stop looking. The tell is the same either way — a conclusion resting on something you never contacted.

When fixes keep failing

Three failed fixes in a row is a different problem than the one being solved. If each attempt reveals the same coupling in a new place, or needs a bigger change than the last one to hold, the architecture is what's wrong — not the last three hypotheses. Say so explicitly and question the pattern before attempting a fourth; a fourth patch on a bad architecture just becomes the fifth.

Question what, though — three correct fixes that did not hold are one wrong model applied three times, and the model lives in the reproduction rather than in the patches. clairvoyance is the pass for that, aimed at the assumptions the bug report's own wording installed: that the failure is where it surfaced, that it is deterministic, that it is one bug rather than two, that the component named is the component at fault.

One oracle a fix loop never consults is the decision the code claims to implement. Where the failing part cites an ADR, a design note, or a spec, read that decision literally against what the code now does before the fourth fix. An implementation that drifted from the decision it names puts every patch inside the drift, and the check that settles it is often named in the decision's own context section. revalidating-decisions is the other direction: the decision itself has gone out of date.

Occasionally a complete investigation turns up nothing fixable: the cause is environmental, an inherent timing dependency, or in code nobody here controls. That's a legitimate outcome — document what was ruled out, add the retry, timeout, or error handling the situation actually calls for, and instrument it for next time. Treat the conclusion with suspicion before accepting it, though: the large majority of "no root cause" verdicts turn out to be an incomplete investigation wearing a conclusion's clothes.

Common mistakes

SymptomReal cause
"The issue is simple, I don't need the loop"Simple bugs have root causes too; skipping the loop on an easy-looking one is how it comes back twice
A tool ran, reported success, and changed nothingA guard was suppressed rather than read; it was refusing because the target no longer matched what the tool acts on
Fix applied, same class of bug reappears elsewhereThe trace stopped at the first plausible cause instead of the actual origin
"Let me just try changing this and see"A change made before a hypothesis exists is a guess wearing an experiment's clothes
Several changes made, then the test run onceWhichever result comes back, nothing was isolated — pass or fail explains neither change
Fix confirmed by one passing runOne run at a nonzero failure rate isn't confirmation — see reproducing-bugs for how many clean runs actually are enough
"One more fix attempt" after two failuresThree failures is an architecture signal, not a reason to try a fourth patch
Test written after the fix, to "prove it works"A test that never watched the bug fail proves nothing about whether it would have caught it
"I don't fully understand it, but this might work"Uncertainty deployed instead of named — say what isn't understood and go investigate exactly that
The same command run four times with the same errorA retry treated as an experiment when nothing differed between the runs
A failure blamed on the network, the runner, or a flaky dependencyAn attribution that was plausible was accepted as tested; the thing it blames was never contacted
The artifact is provably valid and the rendering is still wrongIt was only ever seen through a preview, viewer, or local server, and that is what introduced the defect
A check that fails only in a hidden, suspended, or sleeping host is put down to the hostNobody asked whether production is ever in that state; where it is, the host exposed the defect rather than causing it
"We already ruled that out"The factor was the same in every run, so it was observed rather than tested
A behaviour declared absent because its log line never appearedThe emitter is behind a cache, a level, or a sample the run never tripped; the silence measured the logger
A multi-step credential flow fails with a closed connection or reset socket, not a rejectionThe credential expired mid-flow; the transport layer reported it before anything got to check the value
A hypothesis dropped on a result that was anomalous in several unrelated waysOne wrong idea does not produce several unrelated failures; the experiment broke, so it returned no verdict to act on

Red flags

  • "Quick fix now, investigate properly later"
  • "Just try changing X and see if it works"
  • "It's probably X" — said before anything traced the failure to X
  • Skipping the failing test because the fix "obviously" works
  • Proposing a fix before tracing where the bad value actually originates
  • "One more fix attempt" — said after two have already failed
  • Each fix reveals a new instance of the same problem somewhere else
  • Adding a sleep to make a flaky repro script pass, instead of waiting for the condition
  • Investigating looks like nothing's happening, visibly, in front of people who are waiting; a bad retry's cost is invisible and lands later — that asymmetry is what's pulling, not an actual difference in risk
  • "I'll skip it with a tracked TODO" — said with real intent, about a TODO that dies the moment the release ships and priorities move on
  • An operation attempted again with nothing changed between the attempts, and the identical error read as bad luck rather than as determinism
  • "That's the same for every run, so it can't be that"
  • "I grepped the whole window and it never logged, so it never ran"
  • Reaching for the flag that suppresses a preflight check before answering what condition the check tests
  • "It was the network" — written in a note, then repeated in four more, with no probe run at any point
  • A transport error partway through a credential flow gets "fixed" with a retry instead of a shorter path from fetch to submit
  • A defect about to be written up that has only ever been observed through a preview, a local server, or another stand-in for the real delivery path

Reference

A fixed delay before checking a result is a bet about how fast a machine will always be. The bet is good on the machine that wrote it and gets worse every time the test runs somewhere slower, busier, or more loaded — which is exactly what CI, a shared runner, or next year's dependency bump all are. Wait for the thing actually being tested, not for a number that once happened to be enough.

The pattern

before — guesses at timing:
  sleep 50ms
  read result
  expect result is set

after — waits for the condition:
  wait until result is set, timeout 5s
  read result
  expect result is set

Writing the wait

  • Poll on an interval, not a tight spin. Checking every millisecond burns CPU for no benefit; checking every 10ms is usually indistinguishable to a human and costs nothing.
  • Always include a timeout. A condition that never becomes true must fail loudly with a message naming what it was waiting for, not hang the run.
  • Re-read fresh state inside the loop. Reading the value once before the loop and checking a cached copy inside it waits forever on data that already arrived.
  • Wait for the real condition, not a proxy for it. A count, a status field, an event — whatever the test actually depends on, not a related value that usually changes around the same time.

When a fixed delay is actually correct

Testing timing behavior itself — a debounce interval, a throttle window, a scheduled retry — has no condition to poll for; the delay is the thing under test. Even here, don't guess blind: wait for the triggering condition first, then apply the fixed span, and comment the number so the next reader knows it's derived rather than picked. A tool that ticks every 100ms needs two ticks to prove partial output arrived — the fixed wait is 200ms, documented as two tick-intervals, not a round number pulled from nowhere.

Common mistakes

SymptomReal cause
Test passes locally, fails in CIFixed delay tuned to a faster machine than CI provides
Test occasionally hangs the whole suitePolling loop with no timeout
Test flakes back in after being "fixed"The wait reads a value cached before the loop started, not a fresh read on each check
Increasing the delay "fixes" it for a whileThe delay was never the actual dependency; the flake rate just dropped below what the sample size would catch

More in Debugging and performance