· 4 min read

Print the path

Temporary print statements solve many bugs because they capture a rerunnable chronology with less setup than an interactive debugger.

A line of hare tracks printed across the snow of a frozen lake in Lapland, leading toward a beached boat.
Ximonic (Simo Räsänen), CC BY-SA 3.0

Near midnight, I was attached to the right Node worker and paused inside the right function. The invoice was pending, the call stack looked ordinary, and stepping over charge produced one successful save. I had reproduced nothing.

Why was I single-stepping one worker to understand a race between two? The fact I needed was the order of events across processes, and pausing shows one process at one moment. I removed the breakpoint, added four temporary prints carrying an invoice id and a process id, then ran the stress test again. The terminal finally showed the shape of the bug:

stderr
settle invoice=8 worker=4121 phase=loaded state=pendingsettle invoice=8 worker=4197 phase=loaded state=pendingsettle invoice=8 worker=4121 phase=chargedsettle invoice=8 worker=4197 phase=charged

Two workers loaded the same pending record before either one saved the result. The frame from worker 4121 looked reasonable because it omitted worker 4197, whose two lines supplied the dangerous interleaving.

A print is a narrow question#

Print debugging works best when the line asks one question you can answer on the next run. Did this branch execute for invoice 8? Which worker entered first? A dump of every local variable makes those answers harder to see.

Raw console.log("here") calls lose their meaning as soon as the output scrolls, so I write a disposable helper that prints one JSON line with a stable probe name, the id of the work, and the pid of the runtime that emitted it. The call sites then describe transitions rather than locations:

settle.tsts
probe("loaded", invoice, { state: invoice.state });if (invoice.state === "pending") {  await charge(invoice);  probe("charged", invoice);}

“Loaded” and “charged” survive refactoring better than “line 84” or “after second await.” More importantly, the two events can be compared across workers and across repeated runs.

The debugger keeps one excellent frame#

A breakpoint preserves one rich moment, while temporary prints preserve a cheap sequence across many moments. When the missing fact is order, the small trace usually reaches it faster.

Temporary printsBreakpointrr recording
CapturesOrder — one line per event, comparable across processes and rerunsDepth — the whole stack and object graph at one stopBoth — the full history, plus state you did not think to record
SetupThirty seconds to place a probe, then one rerunAttach to the right process, maybe a child you forgot existedRecord one failing execution, Linux only
Schedule costEvery line does I/O and allocates stringsEach pause joins the schedule you are studyingReplay is deterministic, so the interleaving holds still
Left to right, the capture grows richer and the setup grows heavier.

Debugger authors have built this distinction into their tools. The GDB manual on tracepoints begins with programs whose real-time behavior can change when a debugger interrupts them, so a tracepoint records selected expressions while the target keeps running and lets you read the collected history after.

Pausing is especially awkward around timers, UI events, subprocesses, and lock contention, and printing perturbs the same schedule. I once put a log inside a tight retry loop, watched the flaky test turn green, and spent another hour congratulating the fix before removing the line brought the failure straight back.

Escalate when the question gets richer#

The debugger becomes the faster instrument once you know where to stop and need depth rather than chronology. A conditional breakpoint can catch the rare input without filling a terminal, and a watchpoint can answer who wrote a field when adding prints to every possible writer would be absurd.

Intermittent native failures deserve an even stronger tool: rr, which adds reverse execution through GDB on top of the recording.

I have not measured how often printing wins the first round, and memory corruption in C++ is a different sport from a TypeScript worker race. In ordinary application code, though, the setup cost matters, and a probe can disprove a theory while the debugger is still being aimed.

After fixing the double charge with an atomic claim, I deleted the four settle probes and reran the stress test without them. It passed locally, then failed once on the slower CI worker because its timeout was too tight. I added one print back, this time around the clock.