Debugging Like a Detective: The Art of Finding Bugs Before They Find You
"Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it." — Brian Kernighan
You’ve been staring at the same function for 45 minutes. The test was passing yesterday. Nothing changed. Or at least, you think nothing changed. You add a console.log. Then another. Then twelve more. You comment out half the file. You restart the server. You restart your computer. You restart your career expectations.
Sound familiar?
Debugging is the part of software engineering nobody brags about at parties — but it’s quietly the skill that separates good developers from great ones. The best engineers aren’t the ones who write bug-free code (that person doesn’t exist). They’re the ones who find bugs fast, understand them deeply, and fix them correctly the first time.
This is your guide to becoming one of those people.
Table of Contents
- The Psychology of Debugging
- The Scientific Method for Code
- The Bug Taxonomy: Know Your Enemy
- Rubber Duck Debugging & Other Weird Techniques That Work
- Reading Stack Traces Like a Pro
- The Art of the Minimal Reproduction
- Debugger Tools You Should Actually Be Using
- printf Debugging Done Right
- Debugging in Production (Without Panicking)
- The Top 10 Bugs That Haunt Every Developer
- Preventing Bugs Before They’re Born
- When to Ask for Help (And How to Do It Well)
- The Debugging Mindset: A Philosophy
1. The Psychology of Debugging
Before we talk tools and techniques, let’s talk about your brain — because it’s simultaneously your greatest debugging asset and your worst enemy.
The Curse of Confidence
The number one debugging mistake developers make is assuming they know where the bug is before they’ve confirmed it.
Your brain is a prediction machine. When you read code you wrote, it autocompletes what should be there rather than what is there. This is why you can read your own typo five times and not see it — but a colleague spots it in three seconds.
The Debugging Confidence Trap:
You think the bug is in Module A
→ You look at Module A
→ Module A looks fine (because you wrote it confidently)
→ You look at Module A again, harder
→ You refactor Module A
→ Bug still there
→ You blame JavaScript / the database / Mercury retrograde
→ Bug was in Module B all along
The fix: Treat every assumption as a hypothesis to be tested, not a fact.
Emotional Debugging (The Bad Kind)
There’s a specific kind of cognitive impairment that happens when you’re frustrated and debugging. Your thinking narrows. You try the same thing four times expecting different results. You make changes without understanding them.
Recognize these signs that you need a break:
- You’ve been on the same bug for more than 2 hours without progress
- You’re making changes and hoping (not reasoning) they’ll work
- You’ve opened Stack Overflow for the 6th time and you’re reading the same answers
- You’re considering rewriting the entire module from scratch
The fix is embarrassingly simple: Walk away for 20 minutes. Get coffee. The bug will still be there when you come back, but your prefrontal cortex will be firing again.
The “It Works on My Machine” Phenomenon
┌─────────────────────────────────────────────────────────────┐
│ DEVELOPER'S MENTAL MODEL vs REALITY │
│ │
│ Developer thinks: What's actually happening: │
│ │
│ My code My code │
│ ─────────── ─────────── │
│ Input → Output Input │
│ ↓ │
│ OS timezone │
│ ↓ │
│ Locale settings │
│ ↓ │
│ Node version │
│ ↓ │
│ Environment vars │
│ ↓ │
│ Cached state │
│ ↓ │
│ Output │
└─────────────────────────────────────────────────────────────┘
When your code works locally but breaks in CI or production, the bug isn’t in your logic — it’s in the environment. Always narrow down what’s different between the two environments.
2. The Scientific Method for Code
The best debuggers don’t guess randomly. They apply a structured method. And it turns out the scientific method — the same one from high school chemistry — maps perfectly to debugging.
┌─────────────────────────────────────────────────────────────┐
│ THE DEBUGGING SCIENTIFIC METHOD │
│ │
│ 1. OBSERVE │
│ "The checkout button doesn't work on Firefox mobile" │
│ └── Be specific. Vague observations lead nowhere. │
│ │
│ 2. HYPOTHESIZE │
│ "Probably a CSS issue with the z-index on the button" │
│ └── Generate 2-3 candidates before investigating any. │
│ │
│ 3. PREDICT │
│ "If it's z-index, the button exists in the DOM but │
│ is being covered by another element" │
│ └── What would you expect to see if the hypo is true? │
│ │
│ 4. TEST │
│ Inspect element on Firefox mobile → check computed CSS │
│ └── One change at a time. Never two simultaneously. │
│ │
│ 5. CONCLUDE │
│ "It was z-index — the modal overlay had z-index: 9999 │
│ and wasn't being dismissed properly on touch" │
│ └── Understand WHY, not just what fixed it. │
│ │
│ 6. DOCUMENT │
│ Add a comment. Open a ticket. Update the runbook. │
│ └── The next person (probably you) will thank you. │
└─────────────────────────────────────────────────────────────┘
The Hypothesis Ranking Trick
When you have multiple possible causes, rank them before testing:
Bug: "User's cart empties randomly"
Hypotheses (ranked by probability):
├── 1. Session timeout too short → Most likely (it's "random" = time-based)
├── 2. Cart state not persisted between browser tabs → Plausible
├── 3. Race condition in cart update API → Possible
├── 4. Browser's private mode clearing storage → Unlikely but testable
└── 5. Cosmic rays flipping bits in RAM → Least likely (but never say never)
→ Test #1 first. Check session TTL in config.
This keeps you from spending 3 hours on hypothesis #5 before checking #1.
3. The Bug Taxonomy: Know Your Enemy
Not all bugs are created equal. Understanding what kind of bug you’re dealing with tells you how to find it.
The Eight Species of Bug
┌──────────────────┬─────────────────────────────┬─────────────────────────┐
│ Bug Type │ Description │ Typical Habitat │
├──────────────────┼─────────────────────────────┼─────────────────────────┤
│ Typo/Syntax │ Code says wrong thing │ Anywhere, obvious │
│ Off-by-One │ Loop goes one too far/near │ Arrays, pagination │
│ Race Condition │ Timing-dependent failure │ Async code, threads │
│ Memory Leak │ Resources never freed │ Long-running processes │
│ State Bug │ Wrong value at wrong time │ Complex UIs, stateful │
│ Integration Bug │ Works alone, breaks together│ APIs, microservices │
│ Environment Bug │ Works here, fails there │ Config, paths, env vars│
│ Heisenbug │ Disappears when observed │ Concurrency, debuggers │
└──────────────────┴─────────────────────────────┴─────────────────────────┘
The Heisenbug: The Most Infuriating Bug in Existence
Named after Heisenberg’s uncertainty principle, a Heisenbug is a bug that disappears or changes behavior when you try to observe it. You add a breakpoint — it doesn’t trigger. You add a console.log — it stops happening.
Classic causes:
- The timing introduced by your debugger/logger changes a race condition
- Optimizations disabled in debug mode
- Side effects from measurement code
- Production-only builds behave differently from debug builds
// Classic Heisenbug scenario — race condition
async function fetchUserAndPosts(userId) {
const user = await fetchUser(userId);
const posts = await fetchPosts(userId); // Bug: race condition sometimes
return { user, posts };
}
// When you add logging, the await slightly changes timing:
async function fetchUserAndPosts(userId) {
console.log('fetching user...'); // ← This tiny delay "fixes" the race condition
const user = await fetchUser(userId);
console.log('fetching posts...');
const posts = await fetchPosts(userId); // Appears to work now
return { user, posts };
}
// The REAL fix:
async function fetchUserAndPosts(userId) {
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPosts(userId)
]);
return { user, posts };
}
Rule: If removing your logging “fixes” the bug, you have a Heisenbug. Look for concurrency and timing issues.
The Bohr Bug vs The Mandelbug
| Bug Type | Behavior | Approach |
|---|---|---|
| Bohr Bug | Consistent, reproducible | Standard debugging |
| Heisenbug | Disappears when observed | Add non-intrusive logging |
| Mandelbug | Complex, chaotic causes | Simplify the system |
| Schrödinbug | Works until someone reads the code | It shouldn’t have worked ever |
A Schrödinbug is a personal favorite: code that shouldn’t work by any logic, but runs fine in production for years — until someone reads it and says “wait, this is wrong” and suddenly it starts failing.
4. Rubber Duck Debugging & Other Weird Techniques That Work
The Rubber Duck
The concept is simple and backed by real cognitive science: explain your code, line by line, to an inanimate object.
You: "Okay, so this function takes a user ID..."
Duck: [silence]
You: "...and fetches the user from the database..."
Duck: [silence]
You: "...then it checks if the user is an admin..."
Duck: [silence]
You: "...wait. It checks `user.role === 'admin'` but I'm storing roles
as `user.permissions.role` after the migration."
Duck: [silence, but somehow wise]
You: "Oh my god."
Why it works: Explaining something forces you to construct it linearly, which surfaces assumptions you weren’t aware you were making. Your brain fills in gaps silently when reading; it can’t when speaking.
Modern rubber ducks: An AI assistant, a coworker (willing or not), a blog post you’re writing, the cat.
Change Isolation (Binary Search for Bugs)
If the bug appeared sometime in the last 100 commits, you don’t read all 100 commits. You use binary search:
# Git bisect — the most underused git feature
git bisect start
git bisect bad HEAD # Current commit is broken
git bisect good v2.1.0 # This tag was working fine
# Git checks out the midpoint commit automatically
# You test it, then:
git bisect good # or
git bisect bad
# Git narrows down automatically (log₂(100) ≈ 7 steps to find it)
# When done:
git bisect reset
# BONUS: Automate bisect with a test script
git bisect run npm test -- --grep "checkout flow"
# Git runs your test on each commit and bisects automatically
This narrows 100 commits down to the culprit in ~7 steps. Every developer should know this exists.
The “Explain It to a Junior Dev” Technique
Different from rubber duck because you’re imagining an audience that will ask questions. This forces you to be even more explicit.
Write out your explanation as if you’re submitting a Stack Overflow question. In doing so, you often answer your own question before hitting “Post” — so much so that Stack Overflow calls this phenomenon “rubber duck” questions.
Change One Thing at a Time (The Discipline That Saves Hours)
❌ The impatient approach:
Identify 3 possible issues
→ Fix all three at once
→ Bug disappears
→ You don't know which fix worked
→ Bug comes back in a different form
→ You have no idea why
✅ The disciplined approach:
Identify 3 possible issues
→ Fix issue #1
→ Test
→ Doesn't fix it? Revert #1
→ Fix issue #2
→ Test
→ Bug fixed. You understand WHY.
→ You write a regression test.
5. Reading Stack Traces Like a Pro
A stack trace is not an error message — it’s a map. Most developers read the first line and stop. The pros read the whole thing.
Anatomy of a Stack Trace
Error: Cannot read properties of undefined (reading 'email')
at formatUserProfile (user-profile.js:47:32) ← Where the crash happened
at renderDashboard (dashboard.js:112:18) ← Called by this
at processTicksAndRejections (node:internal/process/task_queues:95:5)
↑
└── Node.js internal — usually not your bug, stop here
┌──────────────┬──────────────┬────────┬──────────┐
│ Function │ File │ Line │ Column │
└──────────────┴──────────────┴────────┴──────────┘
Reading strategy:
1. Read the ERROR TYPE + MESSAGE first → "undefined has no .email"
2. Find YOUR code in the stack (ignore node_modules, internals)
3. The top-most frame in YOUR code is where it crashed
4. Read DOWN to see what called it (the "who caused the situation")
5. The bottom of your code in the stack is the origin of the problem
Real Example: Dissecting a React Error
Uncaught TypeError: Cannot read properties of null (reading 'map')
at ProductList (ProductList.jsx:23)
at renderWithHooks (react-dom.development.js:14985)
at mountIndeterminateComponent (react-dom.development.js:17811)
...
Diagnosis:
Error: "null" doesn't have .map → we're calling .map on null
Location: ProductList.jsx, line 23
Likely cause: products prop is null (not just empty array [])
Go to line 23:
const items = products.map(p => <ProductCard key={p.id} product={p} />);
The question is: WHERE does products come from, and when is it null?
→ Check the parent component. Is the API call loading?
Fix:
const items = (products ?? []).map(p => <ProductCard key={p.id} product={p} />);
// Or add a loading state check before rendering ProductList
Minified Stack Traces (Production)
Production JavaScript is minified. Stack traces become:
TypeError: Cannot read properties of undefined (reading 'n')
at t.e (main.8f3a2c1d.js:1:28945)
at main.8f3a2c1d.js:1:13422
This is useless without a source map.
# Generate source maps in your build:
# webpack.config.js
module.exports = {
devtool: 'source-map', // Generates .map files
// For production (keep maps private):
devtool: 'hidden-source-map' // Maps exist but not referenced publicly
}
# Vite — vite.config.js
export default {
build: {
sourcemap: true // or 'hidden'
}
}
# Upload source maps to error tracking (Sentry, Datadog):
npx @sentry/cli sourcemaps upload --org my-org --project my-project ./dist
6. The Art of the Minimal Reproduction
If you can reproduce a bug in 10 lines of code, you can fix it in 10 minutes. If you can only reproduce it in your full application, you might spend 10 hours.
The Minimum Viable Bug
// Bug: "The currency formatting is wrong for amounts over $999"
// ❌ The lazy report:
// "It's broken in the checkout flow somewhere"
// ✅ The minimal reproduction:
const Intl = require('intl');
function formatCurrency(amount) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
}
console.log(formatCurrency(999)); // "$999.00" ✅
console.log(formatCurrency(1000)); // "$1,000.00" ← Wait, this is correct
console.log(formatCurrency(999.9)); // "$999.90" ← Hmm, also correct
// Turns out the bug was in the COMPARISON, not the formatting:
if (amount > 999) { // Bug: should be >= 1000
applyBulkDiscount();
}
Minimal reproductions help because:
- They force you to understand the bug deeply
- They eliminate all the unrelated noise
- They make the bug trivially shareable
- They often reveal the fix as you build them
How to Build a Minimal Reproduction
Step 1: Start with the failing test case
→ What inputs trigger the bug?
→ What output do you expect? What do you get?
Step 2: Strip away everything unrelated
→ Remove authentication
→ Remove database (use hardcoded data)
→ Remove UI (pure logic)
→ Remove network (use mocked responses)
Step 3: Can you reproduce it in a single file?
→ Aim for < 50 lines
→ If not, you haven't isolated it yet
Step 4: Create a shareable environment
→ CodeSandbox / StackBlitz / JSFiddle
→ A GitHub gist
→ A single runnable script
Step 5: Verify the minimal reproduction still shows the bug
→ You'd be surprised how often it doesn't — and you've solved it!
7. Debugger Tools You Should Actually Be Using
Most developers use console.log. Some developers use a debugger. The difference in debugging speed is enormous.
Chrome DevTools: The Full Power
// You probably know this:
console.log('value:', myVar);
// But do you know these?
// 1. Conditional breakpoints (right-click a breakpoint in DevTools)
// Only pauses when: userId === 'abc123'
// No code changes needed
// 2. Logpoints (log without stopping execution)
// Right-click line → Add logpoint → "User: {user.name}, role: {user.role}"
// 3. console.table — for arrays of objects
console.table(users);
// Renders a sortable, filterable table in the console
// 4. console.time — measure exactly how long code takes
console.time('api-call');
await fetchData();
console.timeEnd('api-call'); // "api-call: 342.51ms"
// 5. console.trace — see where a function is being called from
function doSomething() {
console.trace('doSomething called from:');
}
// 6. console.assert — only logs if condition is false
console.assert(user.role === 'admin', 'Expected admin, got:', user.role);
// 7. debugger statement — works anywhere, even without DevTools open
function suspiciousFunction(data) {
debugger; // ← Execution pauses here when DevTools is open
return process(data);
}
The Watch Expressions Panel
Instead of adding console.log for every variable, add them as watch expressions in DevTools. They update in real-time as you step through code.
DevTools → Sources → Watch panel → (+) Add expression:
Expressions to watch:
├── user.permissions
├── cart.items.length
├── typeof response
├── JSON.stringify(state, null, 2) ← Pretty prints state object
└── new Date(timestamp).toISOString() ← Decode timestamps
Node.js Debugger
# Start Node.js with debugger
node --inspect server.js
node --inspect-brk server.js # --brk pauses at first line
# Then open: chrome://inspect in Chrome
# Or use VS Code's built-in debugger (even better)
VS Code Debugging (The Setup That Pays Off)
// .vscode/launch.json — commit this to your repo!
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Node.js",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/src/index.js",
"env": {
"NODE_ENV": "development",
"DEBUG": "*"
}
},
{
"name": "Debug Jest Tests",
"type": "node",
"request": "launch",
"runtimeExecutable": "node",
"runtimeArgs": [
"--experimental-vm-modules",
"${workspaceFolder}/node_modules/.bin/jest",
"--runInBand", // Run tests serially (debugger-friendly)
"--watchAll=false"
],
"console": "integratedTerminal"
},
{
"name": "Debug Chrome",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/src"
}
]
}
With this config, you set breakpoints in VS Code, press F5, and debug directly in your editor. No Chrome DevTools switching required.
8. printf Debugging Done Right
Sometimes a debugger isn’t available or practical (containerized apps, serverless, async race conditions). Log-based debugging is perfectly valid — but most developers do it badly.
Structured Logging
// ❌ The log that tells you nothing useful in production
console.log('user data:', user);
console.log('here');
console.log('got to this point');
// ✅ Structured logging that gives you context
const log = {
info: (msg, meta = {}) => console.log(JSON.stringify({
level: 'info',
timestamp: new Date().toISOString(),
message: msg,
...meta
})),
error: (msg, meta = {}) => console.error(JSON.stringify({
level: 'error',
timestamp: new Date().toISOString(),
message: msg,
...meta
}))
};
// Usage:
log.info('Processing payment', {
userId: user.id,
orderId: order.id,
amount: order.total,
currency: order.currency
});
// Output (parseable by log aggregators like Datadog, Splunk):
// {"level":"info","timestamp":"2024-06-15T14:32:11.234Z","message":"Processing payment","userId":"usr_abc123","orderId":"ord_xyz789","amount":59.99,"currency":"USD"}
Using the debug Package (Node.js)
// npm install debug
const debug = require('debug');
// Create namespaced loggers
const dbDebug = debug('app:database');
const apiDebug = debug('app:api');
const authDebug = debug('app:auth');
// Only logs when DEBUG env var matches
dbDebug('Connecting to database: %s', dbUrl);
apiDebug('Incoming request: %o', { method: 'GET', path: '/users' });
// Enable specific namespaces:
// DEBUG=app:database node server.js → Only DB logs
// DEBUG=app:* node server.js → All app logs
// DEBUG=* node server.js → Everything
// DEBUG=app:*,-app:database node server.js → All except DB
// Zero performance cost when disabled (no-op function)
Strategic Log Placement
async function processOrder(orderId) {
// Log ENTRY with key identifiers
log.info('processOrder: start', { orderId });
const order = await db.orders.findById(orderId);
// Log key decision points
if (!order) {
log.error('processOrder: order not found', { orderId });
throw new OrderNotFoundError(orderId);
}
log.info('processOrder: order found', {
orderId,
status: order.status,
itemCount: order.items.length,
total: order.total
});
// Log BEFORE external calls (so you know if the call was made)
log.info('processOrder: calling payment provider', {
orderId,
provider: 'stripe',
amount: order.total
});
const payment = await stripe.charge(order);
// Log AFTER external calls with the result
log.info('processOrder: payment result', {
orderId,
paymentId: payment.id,
status: payment.status
});
// Log EXIT
log.info('processOrder: complete', { orderId });
return payment;
}
9. Debugging in Production (Without Panicking)
Production bugs are different. You can’t just attach a debugger. Users are affected. Someone’s boss might be calling.
The Incident Response Mindset
FIRST: Stabilize, THEN Investigate
Step 1: Assess severity (1 minute)
├── Is the entire service down? → Immediate rollback
├── Is one feature broken? → Disable feature flag if possible
└── Is it a data issue? → Stop writes if at risk of corruption
Step 2: Communicate (2 minutes)
└── Post in #incidents: "Investigating reports of [X]. Will update in 10 min."
→ Don't make people chase you for status
Step 3: Gather evidence (before changing anything!)
├── Check error rates in monitoring (Datadog, Sentry, etc.)
├── Check recent deployments (was there a deploy in the last hour?)
├── Check recent infrastructure changes
└── Check external dependencies (is Stripe/Auth0/AWS down?)
→ https://www.githubstatus.com, https://status.aws.amazon.com
Step 4: Investigate with read-only queries first
└── Never run UPDATE/DELETE without SELECT first
Step 5: Fix or mitigate
├── Rollback if cause is clear and recent deploy is culprit
├── Hotfix for well-understood bugs
└── Feature flag disable for isolated features
Step 6: Post-mortem (blameless)
└── What happened → Why it happened → What prevents recurrence
Feature Flags for Instant Rollback
// Using a feature flag service (LaunchDarkly, Unleash, etc.)
// Or a simple database/config-based approach:
async function processCheckout(cart, user) {
// Check if new checkout flow is enabled for this user
const useNewCheckout = await flags.isEnabled('new-checkout-v2', {
userId: user.id,
userSegment: user.plan
});
if (useNewCheckout) {
return await newCheckoutFlow(cart, user); // New code
} else {
return await legacyCheckoutFlow(cart, user); // Safe fallback
}
}
// When new-checkout-v2 bugs in production:
// → Flip flag to false in dashboard (no deploy needed)
// → 100% of users instantly on legacy flow
// → No rollback deployment required
// → Fix the bug, re-enable flag gradually (5% → 25% → 100%)
Distributed Tracing
In microservices, the bug might not be where the error surfaces.
// Using OpenTelemetry for distributed tracing
const { trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('checkout-service');
async function processOrder(orderId) {
return tracer.startActiveSpan('processOrder', async (span) => {
span.setAttribute('orderId', orderId);
try {
const order = await tracer.startActiveSpan('db.findOrder', async (dbSpan) => {
const result = await db.orders.findById(orderId);
dbSpan.setAttribute('found', !!result);
dbSpan.end();
return result;
});
const payment = await tracer.startActiveSpan('stripe.charge', async (paySpan) => {
const result = await stripe.charge(order);
paySpan.setAttribute('payment.status', result.status);
paySpan.end();
return result;
});
span.setStatus({ code: SpanStatusCode.OK });
return payment;
} catch (error) {
span.recordException(error);
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
throw error;
} finally {
span.end();
}
});
}
// Now in Jaeger/Zipkin/Datadog APM, you can see the FULL trace:
// processOrder (350ms)
// └── db.findOrder (45ms)
// └── stripe.charge (305ms) ← HERE'S YOUR BOTTLENECK / FAILURE POINT
10. The Top 10 Bugs That Haunt Every Developer
These bugs have ended careers (not really, but it felt like it).
1. The Off-By-One Error
// Count: [0, 1, 2, 3, 4] — 5 items
const items = ['a', 'b', 'c', 'd', 'e'];
// ❌ Bug: misses last item
for (let i = 0; i < items.length - 1; i++) {
process(items[i]);
}
// ❌ Bug: index out of bounds
for (let i = 0; i <= items.length; i++) {
process(items[i]); // items[5] is undefined
}
// ✅ Correct
for (let i = 0; i < items.length; i++) {
process(items[i]);
}
// Or just:
items.forEach(item => process(item));
// ❌ Classic pagination bug
const page = 1;
const pageSize = 10;
const offset = page * pageSize; // = 10, skips first 10 items!
// ✅ Correct
const offset = (page - 1) * pageSize; // = 0 for page 1
2. Floating Point Arithmetic
// The bug that has surprised every developer at least once
0.1 + 0.2 === 0.3 // false
console.log(0.1 + 0.2); // 0.30000000000000004
// ❌ Comparing floats directly
if (price === 0.30) { ... } // Never do this
// ✅ For money: use integers (cents)
const priceInCents = 30; // Store as 30, not 0.30
const displayPrice = (priceInCents / 100).toFixed(2); // "0.30"
// ✅ For other floats: use epsilon comparison
const EPSILON = 0.000001;
Math.abs(a - b) < EPSILON // "Are a and b basically equal?"
// ✅ Use a library: decimal.js, bignumber.js
import Decimal from 'decimal.js';
new Decimal('0.1').plus('0.2').equals('0.3'); // true
3. The Async/Await Trap
// ❌ Bug: not awaiting a Promise
async function saveUser(userData) {
db.users.save(userData); // Missing await! Returns a Promise, not the result
return { success: true }; // Returns before save completes
}
// ❌ Bug: await in loop (sequential when should be parallel)
async function fetchAllUsers(userIds) {
const users = [];
for (const id of userIds) {
const user = await fetchUser(id); // Waits for each one ← SLOW
users.push(user);
}
return users;
}
// ✅ Parallel fetching
async function fetchAllUsers(userIds) {
return Promise.all(userIds.map(id => fetchUser(id)));
}
// ❌ Bug: unhandled promise rejection
fetchData().then(data => process(data));
// If fetchData() rejects, error is silently swallowed
// ✅ Always handle errors
fetchData()
.then(data => process(data))
.catch(error => log.error('fetchData failed', { error }));
// Or with async/await:
try {
const data = await fetchData();
process(data);
} catch (error) {
log.error('fetchData failed', { error });
}
4. Mutating Shared State
// ❌ Bug: function mutates its input
function applyDiscount(cart, discountPercent) {
cart.total = cart.total * (1 - discountPercent / 100); // Mutates!
return cart;
}
const cart = { total: 100, items: [] };
const discounted = applyDiscount(cart, 10);
console.log(cart.total); // 90 — original was modified!
// ✅ Return a new object
function applyDiscount(cart, discountPercent) {
return {
...cart,
total: cart.total * (1 - discountPercent / 100)
};
}
// ❌ Classic JavaScript array mutation trap
const nums = [3, 1, 4, 1, 5];
const sorted = nums.sort(); // Mutates nums!
console.log(nums); // [1, 1, 3, 4, 5] — original changed
// ✅ Sort a copy
const sorted = [...nums].sort((a, b) => a - b);
5. The Timezone Bug
// The bug that only shows up in production (when users are in other timezones)
// ❌ Bug: treating date strings as local time
const date = new Date('2024-06-15'); // Interpreted as UTC midnight
// In New York (UTC-5): date.getDate() returns 14, not 15!
// ❌ Bug: storing timezone-naive datetimes
const appointmentTime = '2024-06-15 14:00:00'; // What timezone??
// ✅ Always store and transmit in UTC
const now = new Date().toISOString(); // "2024-06-15T19:00:00.000Z" ← UTC explicit
// ✅ Use date-fns or Temporal API for date arithmetic
import { addDays, format, parseISO } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
const meeting = parseISO('2024-06-15T19:00:00Z');
const localTime = formatInTimeZone(meeting, 'America/New_York', 'h:mm a zzz');
// "3:00 PM EDT"
// ✅ The Temporal API (modern browsers, 2024+)
const dt = Temporal.ZonedDateTime.from('2024-06-15T14:00:00[America/New_York]');
dt.toInstant().toString(); // Convert to UTC for storage
6. The null vs undefined vs "" vs 0 vs false Confusion
// All of these are "falsy" in JavaScript:
// false, 0, "", null, undefined, NaN
// ❌ Bug: treating all falsy values the same
function getUsername(user) {
return user.name || 'Anonymous';
}
getUsername({ name: '' }); // 'Anonymous' — but maybe '' is valid!
getUsername({ name: 0 }); // 'Anonymous' — 0 is a weird username but...
// ✅ Be explicit about what you're checking
function getUsername(user) {
return user.name ?? 'Anonymous'; // Only fallback for null/undefined
}
getUsername({ name: '' }); // '' — empty string preserved
getUsername({ name: null }); // 'Anonymous' ✅
// ❌ Bug: optional chaining confusion
const city = user?.address?.city;
// city is undefined if user is null OR if address is null OR if city doesn't exist
// You can't tell WHICH one caused the undefined
// ✅ Be explicit in your fallbacks
const city = user?.address?.city ?? 'City not specified';
11. Preventing Bugs Before They’re Born
The best bug is the one that never makes it into the codebase.
Types as Bug Prevention
// TypeScript: catch entire categories of bugs at compile time
// ❌ JavaScript: runtime error
function formatPrice(price) {
return `$${price.toFixed(2)}`; // Crashes if price is string "9.99"
}
formatPrice("9.99"); // TypeError at runtime
// ✅ TypeScript: caught at compile time
function formatPrice(price: number): string {
return `$${price.toFixed(2)}`;
}
formatPrice("9.99"); // ← TypeScript error: Argument of type 'string' is not assignable to parameter of type 'number'
// ✅ Stronger types with branded types
type UserId = string & { readonly brand: unique symbol };
type ProductId = string & { readonly brand: unique symbol };
function getUser(id: UserId) { /* ... */ }
const userId = "usr_123" as UserId;
const productId = "prod_456" as ProductId;
getUser(userId); // ✅
getUser(productId); // ← TypeScript error! Can't mix up IDs
Property-Based Testing
Instead of testing specific examples, test invariants (properties that should always be true).
// npm install fast-check
import fc from 'fast-check';
// ❌ Example-based test: only tests 3 cases
test('sort returns sorted array', () => {
expect(sort([3,1,2])).toEqual([1,2,3]);
expect(sort([5,4])).toEqual([4,5]);
expect(sort([])).toEqual([]);
});
// ✅ Property-based test: tests 100 random inputs
test('sort invariants always hold', () => {
fc.assert(fc.property(
fc.array(fc.integer()),
(arr) => {
const sorted = sort([...arr]);
// Property 1: Same length
expect(sorted).toHaveLength(arr.length);
// Property 2: All elements present
expect(sorted.sort()).toEqual([...arr].sort());
// Property 3: Actually sorted
for (let i = 0; i < sorted.length - 1; i++) {
expect(sorted[i]).toBeLessThanOrEqual(sorted[i + 1]);
}
}
));
});
// fast-check automatically finds edge cases: [0], [-1,0], [MAX_INT]
Code Review as Bug Prevention
Effective Code Review Checklist:
Logic & Correctness:
├── Does the code do what it says it does?
├── Are edge cases handled? (empty, null, max values)
├── Are error cases handled?
└── Are there any off-by-one risks?
Security:
├── Is user input sanitized before use in DB/HTML?
├── Are any secrets hardcoded?
└── Are permissions/auth checked at the right layer?
Performance:
├── Any N+1 query problems?
├── Are large datasets paginated?
└── Any potential memory leaks?
Maintainability:
├── Would a new team member understand this in 6 months?
├── Are complex sections commented?
└── Is naming clear and intentional?
12. When to Ask for Help (And How to Do It Well)
Knowing when to ask for help is a skill. Knowing how to ask is an art.
The 30-Minute Rule
Spend 30 minutes genuinely trying to solve a bug before asking for help. This time forces you to:
- Form a coherent explanation of the problem
- Try the obvious solutions
- Gather relevant information
After 30 minutes with no progress: ask. Struggling alone for 3 hours is not productive — it’s just pride.
How to Ask a Good Question
Bad question:
"My code doesn't work, can anyone help?"
Good question:
"I'm getting a 'Cannot read property of undefined' error in my
checkout flow, specifically in the `applyDiscount()` function.
**What I expected:** The function returns the discounted price.
**What I got:** `TypeError: Cannot read properties of undefined (reading 'total')`
**Stack trace:**
TypeError: Cannot read properties of undefined (reading ‘total’) at applyDiscount (checkout.js:47)
**Relevant code:**
```javascript
function applyDiscount(cart, discount) {
return cart.total * (1 - discount); // Line 47
}
What I’ve tried:
- Logging
cartbefore line 47 — it logsundefined - Checking where
applyDiscountis called — the cart object seems valid there
I think the cart is getting undefined somewhere between the call site and the function, but I can’t see how.”
The good question gives:
- Context (what is this code doing)
- Expected vs actual behavior
- The error message and stack trace
- The relevant code (not the whole file)
- What you've already tried
---
## 13. The Debugging Mindset: A Philosophy
After all the tools and techniques, the most important thing is how you *think* about bugs.
### Bugs Are Information, Not Failures
Every bug is your codebase telling you something true about the world that your mental model got wrong. The bug isn't lying. The compiler isn't lying. The database isn't lying.
*You* believed something that wasn't true. The bug showed you what was actually true.
This reframing changes everything. Instead of "why is this stupid thing broken?" it becomes "what is this showing me about my assumptions?"
### The Bug Was Always There
Your code doesn't suddenly become buggy. The bug was there when you wrote it — you just hadn't found the input that exposed it yet. This means:
- Bugs in production aren't bad luck; they're undiscovered bugs from development
- Every bug fixed is an opportunity to add a test that would have caught it
- The best time to write that test was before the bug; the second best time is now
### On the Joy of Debugging
Here's a secret: debugging is *fun* when you stop fighting it. It's a logic puzzle. A mystery. The code is Sherlock Holmes' crime scene, and you are the detective.
Every clue matters. The error message is a clue. The timing is a clue. Which users are affected is a clue. What was deployed last is a clue. The behavior under different inputs is a clue.
When you find the bug — especially a devious one — the satisfaction is real. That "AHA!" moment is one of the genuine pleasures of software engineering.
Enjoy the hunt.
“The most effective debugging tool is still careful thought, coupled with judiciously placed print statements.” — Brian Kernighan
---
## Quick Reference: The Debugging Cheat Sheet
┌──────────────────────────────────────────────────────────────────┐ │ DEBUGGING QUICK REFERENCE │ ├──────────────────┬───────────────────────────────────────────────┤ │ Bug disappeared │ Heisenbug — look for race conditions │ │ Works locally │ Check: env vars, node version, timezone, OS │ │ Appeared lately │ git bisect to find the culprit commit │ │ Can’t reproduce │ Add structured logging, capture more context │ │ Too complex │ Build a minimal reproduction │ │ Stuck >30 min │ Rubber duck it, then ask for help │ │ Production down │ Stabilize first, investigate second │ │ Off-by-one? │ Check every loop, slice, and index carefully │ │ Async weirdness │ Check: missing await, unhandled rejection │ │ NaN / undefined │ Check: null coalescing, type coercion │ ├──────────────────┴───────────────────────────────────────────────┤ │ Remember: The bug is not lying. Your assumptions might be. │ └──────────────────────────────────────────────────────────────────┘
---
*Found a bug in this article? The irony would be appreciated. Open a PR.*
*Tags: `debugging` `developer-tools` `javascript` `best-practices` `software-engineering`*