Debugging Techniques

Difficulty: Beginner

Debugging is the systematic process of finding and fixing errors in your code. While it might seem like an art, effective debugging follows predictable patterns and leverages powerful tools built into every modern browser. Mastering these techniques will dramatically reduce the time you spend hunting down bugs and make you a more productive developer.

The console object offers far more than just console.log(). The console.table() method displays arrays and objects in a clean tabular format, making it easy to compare data at a glance. console.group() and console.groupEnd() let you organize related log statements into collapsible sections, which is invaluable when debugging loops or recursive functions. console.time() and console.timeEnd() measure execution time with millisecond precision, helping you identify performance bottlenecks without external profiling tools.

Browser DevTools are the most powerful debugging environment available to JavaScript developers. The Sources panel lets you set breakpoints by clicking on line numbers, and execution pauses at those points so you can inspect every variable in scope. The Call Stack panel shows how you arrived at the current line, and the Scope panel displays all accessible variables organized by scope level. You can also set conditional breakpoints that only pause when a specific expression evaluates to true, which is essential for debugging issues that only occur with certain data.

The debugger statement is a programmatic breakpoint that you place directly in your code. When DevTools is open and execution reaches a debugger statement, the browser pauses just like it would at a manual breakpoint. This is particularly useful for debugging code that runs in callbacks, event handlers, or dynamically loaded scripts where setting breakpoints in the UI might be difficult. Remember to remove debugger statements before committing your code.

Step-through debugging is the process of executing code one line at a time while observing state changes. DevTools provides four stepping controls: Step Over (execute the current line and move to the next), Step Into (enter the function being called), Step Out (finish the current function and return to the caller), and Resume (continue until the next breakpoint). Learning when to use each stepping mode is key to efficient debugging.

Reading stack traces is a fundamental skill. A stack trace shows the chain of function calls that led to an error, with the most recent call at the top. Each line typically shows the function name, file name, and line number. By reading from top to bottom, you can trace the execution path and identify where things went wrong. Combined with source maps, stack traces work even with minified or transpiled code in production environments.

Code examples

Console methods beyond log

// console.table for structured data
const users = [
  { name: 'Alice', age: 28, role: 'Developer' },
  { name: 'Bob', age: 34, role: 'Designer' },
  { name: 'Charlie', age: 22, role: 'Intern' },
];
console.table(users);

// console.group for organized output
console.group('User Processing');
console.log('Loading users...');
console.group('Validation');
console.log('Checking names...');
console.log('Checking emails...');
console.groupEnd();
console.log('Processing complete');
console.groupEnd();

// console.time for performance measurement
console.time('arrayOperation');
const arr = Array.from({ length: 100000 }, (_, i) => i);
const sum = arr.reduce((a, b) => a + b, 0);
console.timeEnd('arrayOperation');

// console.count for tracking calls
function processItem(type) {
  console.count(type);
}
processItem('fruit');
processItem('vegetable');
processItem('fruit');
processItem('fruit');

console.table renders arrays as formatted tables. console.group creates collapsible sections. console.time measures execution duration. console.count tracks how many times each label is logged - great for verifying function call frequency.

The debugger statement

function calculateDiscount(price, userType) {
  let discount = 0;
  
  // Execution pauses here when DevTools is open
  debugger;
  
  if (userType === 'premium') {
    discount = price * 0.2;
  } else if (userType === 'member') {
    discount = price * 0.1;
  }
  
  const finalPrice = price - discount;
  return finalPrice;
}

// When DevTools is open, this will pause at the debugger statement
// You can then inspect: price, userType, discount
const result = calculateDiscount(100, 'premium');
console.log('Final price:', result);

The debugger statement acts as a programmatic breakpoint. When the browser's DevTools is open, execution pauses at that line, letting you inspect all variables in scope and step through the remaining code line by line.

Reading stack traces

function innerFunction() {
  throw new Error('Something failed in inner function');
}

function middleFunction() {
  innerFunction();
}

function outerFunction() {
  middleFunction();
}

try {
  outerFunction();
} catch (error) {
  console.log('Error:', error.message);
  console.log('\nStack trace:');
  // Split and display each line of the stack
  const stackLines = error.stack.split('\n');
  stackLines.slice(0, 4).forEach(line => {
    console.log(line.trim());
  });
}

The stack trace reads top-to-bottom from where the error was thrown back to the original caller. Line 2 in innerFunction is where the error originated, called from middleFunction at line 6, which was called from outerFunction at line 10.

Practical debugging strategies

// Strategy 1: Binary search debugging
// When you don't know where the bug is,
// add a log halfway through the code
function processOrder(order) {
  const items = order.items;
  const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
  console.log('[DEBUG] subtotal:', subtotal); // Is subtotal correct?
  
  const tax = subtotal * 0.08;
  const shipping = subtotal > 50 ? 0 : 5.99;
  const total = subtotal + tax + shipping;
  
  return { subtotal, tax, shipping, total };
}

// Strategy 2: console.assert for assumptions
function divide(a, b) {
  console.assert(b !== 0, 'Division by zero detected!', { a, b });
  return a / b;
}

// Strategy 3: JSON.stringify for deep inspection
const nested = { user: { profile: { settings: { theme: 'dark' } } } };
console.log('Shallow:', nested);
console.log('Deep:', JSON.stringify(nested, null, 2));

// Strategy 4: Labeling logs for filtering
console.log('[AUTH]', 'User login attempted');
console.log('[DB]', 'Query executed: SELECT...');
console.log('[AUTH]', 'Token generated');

const result = processOrder({ items: [{ price: 25, qty: 2 }, { price: 10, qty: 1 }] });
console.log('Order:', result);
divide(10, 0);

Four practical strategies: (1) Place strategic logs to narrow down where values go wrong. (2) Use console.assert to validate assumptions - it only logs when the condition is false. (3) JSON.stringify with indentation reveals deeply nested objects that console.log truncates. (4) Label prefixes like [AUTH] or [DB] let you filter logs by category.

Key points

Concepts covered

console methods, DevTools, debugger statement, breakpoints, stack traces, debugging strategies