The fastest path to fixing a bug is forming a hypothesis before you change any code. Developers who jump straight to adding console.log statements are pattern-matching on past bugs rather than reasoning about the current one. The scientific method — observe, hypothesize, test — applies directly to debugging and consistently outperforms trial-and-error approaches.
The Scientific Method Applied to Bugs
Before touching any code, write down (literally write, even if it is just a comment) your hypothesis about what is wrong. "I think the authentication middleware is not receiving the token correctly" or "I think the date comparison is using the wrong timezone." A written hypothesis forces precision.
Then design a minimal test of that hypothesis. If the hypothesis is wrong, you learn something concrete. If it is right, you know exactly where to look.
This sounds slow. It is not. The alternative — changing things until the bug goes away — is slower on average, and it leaves you unsure whether you fixed the bug or masked it.
The failure mode most developers hit: they encounter an error message, search for the error message online, find a Stack Overflow answer, apply the fix, and the bug goes away. But they never understood why. Two weeks later, a variant of the same bug appears and they go through the same process again. Understanding the root cause takes more time upfront but saves that time multiple times over.
Reading Stack Traces: Start at the Top
Stack traces read bottom-to-top in terms of call order (the most recent call is at the top), but the useful information is almost always at the top, not the bottom.
The top of the stack trace shows the exact line where the error occurred and what the error was. The middle of the stack trace often shows framework internals (Express middleware, React renderer, Node.js event loop internals) that you did not write and cannot change. The bottom shows the entry point.
When you see a wall of stack trace and your instinct is to look at the bottom, resist it. Find the first line that refers to a file you wrote. That is where to start.
Node.js stack traces with async functions can be confusing because the stack unwinds differently. Enable async stack traces in Node.js 12+ by default — they are on in development but may need configuration in production for performance reasons.
Browser DevTools: The Tools Most Developers Underuse
Breakpoints: Click the line number in the Sources tab to set a breakpoint. When execution hits that line, it pauses. You can inspect every variable in scope, step line by line, and step into function calls. This is faster and more precise than adding console.log for each variable you want to inspect.
Conditional breakpoints: Right-click a line number and set a condition. The breakpoint only triggers when the condition is true — e.g., userId === "specific-id". Essential for debugging inside loops or frequently-called functions.
Watch expressions: In the debugger panel, add watch expressions that evaluate in the current scope at each breakpoint. Instead of console.log(someObject.deeply.nested.value), add a watch expression and it updates automatically as you step through code.
Network waterfall: The Network tab shows every HTTP request, its timing, headers, and response body. For debugging slow page loads or API errors, this is often the first place to look. Sort by duration to find the slowest requests immediately.
Performance profiler: Record a performance profile (Ctrl+Shift+P in the Performance tab) to see a flame chart of where JavaScript execution time is spent. For UI performance issues, this tells you exactly which function is running too long. For API performance, the server-side profiler is more appropriate.
Node.js Debugging: The Debugger You Already Have
Node.js has a built-in debugger that integrates with Chrome DevTools and VS Code.
Start Node.js with the --inspect flag:
node --inspect src/server.ts
# or for Next.js
NODE_OPTIONS='--inspect' pnpm dev
Open chrome://inspect in Chrome. Your Node.js process appears under "Remote Targets." Click "inspect" to open a Chrome DevTools window connected to your Node.js process. Set breakpoints, inspect variables, and step through server code the same way you would with browser JavaScript.
VS Code's debugger integrates more cleanly if you prefer to stay in the editor. Create a .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug App",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["dev"],
"env": { "NODE_OPTIONS": "--inspect" }
}
]
}
Press F5 to start the debugger. Set breakpoints in your TypeScript files directly. VS Code handles source maps automatically.
Binary Search Debugging: Isolate the Problem Systematically
When you have a bug in a large block of code and no idea where it starts, binary search debugging works faster than reading line by line.
Comment out half the code. Does the bug still occur? If yes, the bug is in the remaining half. If no, the bug was in the commented-out half. Repeat, halving the search space each time.
This works for more than just code. If a bug appears after a specific sequence of actions in the UI: do the first half of the actions. Does the bug appear? No? Do the second half. Keep halving until you have isolated the exact action that triggers it.
For bugs that appear after a code change: git bisect (covered in the Git guide) automates this process across commit history.
The Rubber Duck Method
Explaining your code to someone — or something — forces you to articulate assumptions you are holding implicitly. Saying out loud "so this function receives a user object, and I expect the email to be present, and then it..." often stops you mid-sentence when you realize the email might not be present in the specific scenario you are debugging.
The rubber duck does not need to be a duck. A stuffed animal, a terminal window where you type your explanation, or an actual colleague all work. The act of articulation is the mechanism.
When to Use a Debugger vs Logging vs Test Isolation
Use the debugger when: you want to inspect complex object state, the bug involves unexpected control flow (the code is not going where you think it is), or you need to step through someone else's library code to understand its behavior.
Use logging when: the bug is intermittent (debuggers stop execution, which changes timing-sensitive behavior), you need a history of values over time, or the bug is in production and you cannot attach a debugger.
Use test isolation when: you can write a failing test that reproduces the bug. This is the best approach because the test becomes permanent documentation and prevents regression.
Time-Travel Debugging: Replay.io
Replay.io records browser sessions and lets you step backward and forward through the execution, inspect variable state at any point, and add console.log statements retroactively to a recording. This is genuinely useful for bugs that only reproduce in specific user environments or with specific data.
The workflow: give a user a link, they reproduce the bug, Replay captures the full execution, you debug the recording. No more "works on my machine."
The limitation: it requires installing the Replay browser, which users may be reluctant to do. Most useful for internal tools, beta users willing to help debug, or your own testing.
Keep Reading
- HTTP Client Testing Guide — debugging at the API level with proper test coverage
- Git Advanced Guide for Developers — using git bisect to find which commit introduced a bug
- Modern Unix Tools Guide — terminal tools that help diagnose problems at the system level
Pristren builds AI-powered software for teams. Zlyqor is our all-in-one workspace — chat, projects, time tracking, AI meeting summaries, and invoicing — in one tool. Try it free.