How to Debug Code Step by Step — Beginner-Friendly Guide
Debugging is a skill, not luck. Every experienced developer has a systematic approach to finding bugs — and beginners who learn this approach stop spending hours stuck on problems that experts solve in minutes. This guide teaches you the mental model, the tools, and the systematic process to debug any code: from simple print statement debugging to using breakpoints in a full IDE debugger, to diagnosing production-only issues.
90%
of bugs found by adding a single log statement in the right place
Bisect
divide and conquer — binary search for where the bug lives
Read errors
the error message tells you exactly what's wrong
Rubber duck
explaining your code out loud finds most bugs
The Debugging Mindset — What Most Beginners Get Wrong
The biggest mistake beginners make is randomly changing code hoping to stumble on a fix. This approach takes longer, introduces new bugs, and doesn't build the skills to prevent similar bugs in the future. Systematic debugging is the opposite: form a hypothesis about what's wrong, test it with the minimum change needed to verify or falsify it, then act.
The core debugging insight
A bug is a gap between what you think the code does and what it actually does. Debugging is the process of finding where that gap is. The most important skill is NOT guessing — it's forming a hypothesis and testing it systematically. Every log statement you add is an experiment: "Is X true at this point?"
The 7-Step Debugging Process
Read the error message completely
Most beginners skip this step. The error type, message, file name, and line number tell you exactly where to look. "TypeError: Cannot read property 'name' of undefined at processUser (app.js:42)" → line 42, something is undefined. The stack trace shows you every function call that led there.
Reproduce the bug reliably
Before fixing, confirm you can reproduce the bug consistently. What exact inputs or steps trigger it? What browser, OS, or environment? If you can't reproduce it, you can't verify the fix. A fix that only "seems to work" without reproduction is not a real fix.
Isolate where the bug occurs
Add log statements around the suspected area. Binary search: does the bug happen before or after the midpoint of the code? If you have 100 lines of code, add a log at line 50. If the bug happens after, focus on lines 50–100. Repeat until you find the exact line.
Inspect the actual values
console.log(typeof x, x) to see what x actually is — not what you think it should be. console.log(JSON.stringify(obj, null, 2)) for objects. The bug is almost always "this value is not what I expected at this point in the code."
Form a hypothesis and test it
Don't randomly change code. Form a specific theory: "I think x is undefined because the API call hasn't resolved yet." Verify the theory first with a log, then implement the fix. If your theory was wrong, find out why before changing the hypothesis.
Fix one thing at a time
Changing multiple things simultaneously makes it impossible to know what fixed the bug — or whether you introduced a new bug in the process. Make one change, test it, verify, then proceed to the next change.
Verify the fix and test edge cases
Run the code with the original failing input. Then test edge cases: empty input, null values, boundary values, maximum values. Confirm the fix didn't break any existing functionality by running the full test suite.
Print Debugging — The Universal Tool
// ❌ Bad debugging — log tells you nothing
function processUser(user) {
console.log("here"); // tells you the code reached this line — that's it
return user.name.toUpperCase();
}
// ✅ Good debugging — log type AND value
function processUser(user) {
console.log("[processUser] user:", typeof user, user);
console.log("[processUser] user.name:", typeof user?.name, user?.name);
return user.name.toUpperCase();
}
// ✅ Even better — JSON.stringify for deep object inspection
function processOrder(order) {
console.log("[processOrder] INPUT:", JSON.stringify(order, null, 2));
const result = calculateTotal(order.items);
console.log("[processOrder] OUTPUT:", result);
return result;
}
// ✅ console.table for arrays of objects — renders as a table in DevTools
const users = [
{ id: 1, name: "Alice", role: "admin" },
{ id: 2, name: "Bob", role: "user" }
];
console.table(users); // Much easier to read than console.log for arrays
// ✅ Conditional logging — only log when condition is true
const DEBUG = process.env.NODE_ENV !== 'production';
const log = (...args) => DEBUG && console.log('[DEBUG]', ...args);
// ✅ console.time for performance debugging
console.time('fetchUsers');
const users2 = await fetchUsers();
console.timeEnd('fetchUsers'); // prints: "fetchUsers: 342ms"
// ✅ console.error for errors (shows red in console)
// ✅ console.warn for warnings (shows yellow)
// ✅ console.group / console.groupEnd for organized outputUsing Browser DevTools Debugger
For complex bugs — especially in async code, complex object state, or issues that are hard to reproduce with logs — the browser debugger is more powerful. It lets you pause execution, inspect all variables, and step through code line by line.
Setting breakpoints
Open DevTools (F12) → Sources tab → find your file → click the line number. A blue dot appears. When the code reaches that line, execution pauses and you can inspect all variables in scope in the right panel (Scope section).
Conditional breakpoints
Right-click a line number → "Add conditional breakpoint". Only pauses when the condition is true (e.g., i === 499 to debug iteration 500 of a loop, or userId === "problem-id"). Invaluable for debugging specific cases without pausing on every iteration.
Watch expressions
In Sources panel → Watch section → click + → add any expression. The expression is evaluated in real-time as you step through code. Watch things like array.length, obj.status, or complex expressions like items.filter(x => x.price > 100).length.
Call stack inspection
The Call Stack panel shows every function call that led to the current paused line, from bottom (first called) to top (current). Tells you HOW you got to the current line — who called whom. Essential for async debugging where the call sequence is non-obvious.
Debugging Async Code — The Special Challenge
// ❌ Classic async bug — missing await
async function getUser(id) {
const user = fetchUser(id); // ❌ forgot await — user is a Promise, not a User
console.log(user.name); // TypeError: Cannot read property 'name' of undefined
}
// ✅ Fix: add await
async function getUser(id) {
const user = await fetchUser(id); // ✅ waits for the Promise to resolve
console.log(user.name); // "Alice"
}
// ❌ Lost error in Promise chain
fetchUser(id)
.then(user => processUser(user))
// If processUser throws, the error is silently swallowed
.then(result => saveResult(result));
// ✅ Always catch errors
fetchUser(id)
.then(user => processUser(user))
.then(result => saveResult(result))
.catch(error => {
console.error('[getUser] failed:', error.message, error.stack);
// Now you see the error instead of silent failure
});
// ✅ With async/await + try/catch
async function processAndSave(id) {
try {
const user = await fetchUser(id);
const result = processUser(user);
await saveResult(result);
} catch (error) {
console.error('[processAndSave] error at step:', error.message);
throw error; // re-throw so the caller knows it failed
}
}
// ✅ Debugging parallel async operations
const [user, orders, payments] = await Promise.all([
fetchUser(id).catch(e => { console.error('fetchUser failed:', e); return null; }),
fetchOrders(id).catch(e => { console.error('fetchOrders failed:', e); return []; }),
fetchPayments(id).catch(e => { console.error('fetchPayments failed:', e); return []; }),
]);
// Individual catches let you see which request failedWhen the Bug Is in Production But Not Local
Check environment variables
Different API keys, database URLs, feature flags between dev and prod. Log process.env variables (except secrets) at startup. A missing or incorrect env variable is the most common "works locally, fails in prod" cause.
Check data differences
Production data has edge cases that test data doesn't. A customer with a null middle name, a product with an empty price array, an account with zero orders. These cases break code that worked fine with your test data.
Add detailed logging to production
Use a logging service (Sentry, Datadog, CloudWatch, LogRocket). Log key events with context: user ID, request ID, critical variable values. Turn on verbose logging for suspected code paths using feature flags without redeploying.
Check for race conditions and timing
Under production load, timing-dependent bugs appear. Two requests modifying the same data simultaneously. A function called before an async initialization completes. These are invisible locally at low concurrency but appear under real load.
The rubber duck technique — science-backed