When something breaks, my worst instinct is to start changing things. This is the checklist I use to override that instinct, illustrated with real bugs from my own projects.
The routine
1. Reproduce it
If I can't make it fail on demand, I'm guessing. Before touching any code, find the smallest sequence of steps that triggers the problem every time. If it's intermittent, find what makes it more likely (timing, data, load).
2. Read the actual error
All of it, including the line I'd normally scroll past. Error messages are often precise. The half I skip is usually the half that names the culprit.
3. Shrink the problem
Remove code until the bug disappears. The last thing removed is the prime suspect. This is slow and boring and it works better than staring.
4. Form one hypothesis and test it
"I think X is happening because Y." Then change one thing to check. If you can't state the hypothesis, you're not debugging, you're shuffling.
5. Check the boring stuff
Environment variables, wrong branch, stale build, cached response, a service that isn't running. Boring causes are far more common than exotic ones.
6. Explain it aloud
Rubber-duck it, literally. Half of bugs dissolve mid-sentence.
7. Write down the cause
Future me will meet this bug again, or a cousin of it.
Real examples
Here's what the routine looks like applied to bugs from my own project write-ups.
The silent failure (step 5: boring causes)
On Day 3 I wrote about deploys that failed for two weeks. The cause was a boring environment difference: Vercel skipped a postinstall script that my laptop ran. If I'd started with "what is different between my machine and the host?", I'd have found it sooner.
The TTS that never spoke (step 1 and 5)
In Knot.ai, the voice engine's text-to-speech fails silently: the agent joins, reports "running", and never publishes audio. A silent failure has no error to read. The fix was to create the signal: a /health/tts pre-flight that names the missing credentials, and an 18-second audio watchdog in the browser. Lesson: when a system fails silently, add observability before you add guesses.
The detector that flagged everything (step 4: one hypothesis)
Knot.ai's contradiction detector fired on any shared long word, so by round three it flagged everything. The hypothesis was "the match rule is too loose." The fix: tighten it to a real self-retraction, an "I/we" subject plus topical overlap right after a negation. One rule, one change, one re-test.
The spinner that never stopped (step 3: shrink)
In Whiteboard.ai, viewers sat on "Saving…" forever. The question that cracks it is a shrinking one: what writes to the document when a viewer merely opens it? The answer: the store stamped a schema version at construction, an update the server correctly rejects for viewers. Moving the stamp to the first local edit fixed it.
The recursion in the policies (step 2: read the error)
In CardBridge, two Row Level Security policies referenced each other's tables and Postgres reported infinite recursion. The error literally named the problem. Breaking the cycle with SECURITY DEFINER helper functions fixed it.
The rule behind the rules
Change one thing at a time.
Bugs get worse fastest when you change three things at once, because then you can't tell which change helped, which hurt, and which did nothing.
A debugging log template
Keep a tiny file open while you work:
BUG: viewers stuck on "Saving..."
REPRO: open a board as a viewer, wait 5 s
HYPOTHESIS 1: server rejects an update we send -> CHECK: log rejected frames
RESULT: yes, schema version stamp on construct
FIX: stamp on first local edit
CAUSE (for next time): clients that cannot write must not write on open
It takes two minutes and turns a frustrating hour into a recorded lesson.
When to stop and ask for help
- You've been stuck for 30–45 minutes without a new hypothesis.
- You can explain the problem but not why your hypotheses failed.
- The bug might be in code you don't own.
Asking early is cheaper than being heroic. Write the question as if the other person has no context: what you expected, what happened, and what you've already tried.
git bisect: debugging with history
When something worked last week and doesn't now, let git find the culprit. Bisect does a binary search over commits.
git bisect start
git bisect bad # the current commit is broken
git bisect good v1.2.0 # a commit (or tag) you know worked
# git checks out a commit halfway between. Test it, then tell git:
git bisect good # or: git bisect bad
# ...repeat; after a handful of steps, git names the first bad commit.
git bisect reset # return to where you started
With 100 commits in range, you need at most about seven steps. You can even automate it with a script that exits 0 for good and non-zero for bad:
git bisect run npm test
Logging that actually helps
console.log("here") is the debugging equivalent of shouting into a room. Better habits:
-
Log values with labels:
console.log('claim input', { riderId, triggerType }). - Log at boundaries: where data enters and leaves your function.
- Use levels: debug for noise, info for events, warn and error for problems.
- Never log secrets or personal data.
- Remove or lower the level of temporary logs afterwards.
Debugging local versus production
Production bugs are harder because you can't poke at them freely. Some principles:
- Reproduce locally first, with production-like data and settings.
- Add observability before guessing: a health check, a log line, a metric. (The silent TTS failure in Knot.ai needed exactly this.)
- Compare environments: versions, env vars, regions, install policies.
- Change one thing, deploy, check: with small deploys, a regression points at a small diff.
- Prefer reversible fixes: feature flags and quick rollbacks are better than heroic patches.
The rubber duck, made practical
When you explain a bug aloud, force yourself to answer three questions:
- What did I expect to happen?
- What actually happened?
- What's the first point where those two diverge?
That third question is the one that finds bugs. Writing the answer as an actual message to a teammate (even one you never send) is a powerful version of the duck.
Common categories of boring causes
| Category | Examples |
|---|---|
| Environment | Wrong Node version, missing env var, different OS |
| State | Stale cache, old build artifact, leftover data |
| Timing | Race condition, slow network, timeout too short |
| Permissions | Missing policy, wrong key, CORS |
| Data | Null where a value was assumed, unexpected format, timezone |
| Assumptions | "This can never be empty", "this runs once" |
When stuck, walk down this table and ask whether each could apply.
After the fix
Add a test that would have caught it, or at least a comment explaining the trap. A bug you fix twice has taught you nothing the first time.
What's step zero in your routine? And what's the most embarrassing root cause you've ever found?

Top comments (0)