Debugging Techniques and Tools in JavaScript

Debugging Techniques and Tools in JavaScript

Debugging Techniques and Tools in JavaScript

For a long time, my entire debugging strategy was console.log sprinkled everywhere, followed by a lot of guessing. It worked, eventually, but it was slow and I often missed the actual root cause. Learning to use the browser’s and Node’s real debugging tools properly changed how quickly I could track down bugs — sometimes cutting an hour of guesswork down to a couple of minutes. This article walks through the debugging techniques and tools I actually rely on today.

Beyond console.log: The Console API in Full

console.log is only the entry point. The Console API has a lot more depth than most people use:

console.log('Basic log');
console.warn('Something looks off');
console.error('Something went wrong');

console.table([
  { name: 'Alice', age: 30 },
  { name: 'Bob', age: 25 },
]); // renders a formatted table in DevTools

console.group('User details');
console.log('Name: Alice');
console.log('Age: 30');
console.groupEnd();

console.time('fetchData');
await fetchData();
console.timeEnd('fetchData'); // Output: fetchData: 123.45ms

console.assert(1 === 2, 'This assertion failed!'); // only logs if condition is false

console.trace('Trace this call'); // prints a full stack trace

console.table in particular has saved me a huge amount of time when inspecting arrays of objects — far easier to scan than a wall of nested object logs.

Using Breakpoints Instead of console.log

The single biggest upgrade to my debugging workflow was switching from console.log to actual breakpoints in Chrome DevTools’ Sources panel. Clicking a line number sets a breakpoint; execution pauses there, and I can inspect every variable in scope, step through code line by line, and even modify values live to test a fix without redeploying.

function calculateTotal(items) {
  let total = 0;
  for (const item of items) {
    total += item.price * item.quantity; // set a breakpoint here
  }
  return total;
}

I can also add a debugger; statement directly in code, which pauses execution automatically whenever DevTools is open:

function processOrder(order) {
  debugger; // execution pauses here when DevTools is open
  return validateOrder(order);
}

I use debugger; sparingly and remove it before committing — it’s easy to forget and accidentally ship it.

Conditional and Logpoint Breakpoints

For bugs that only occur under specific conditions (say, the 500th iteration of a loop), a plain breakpoint is impractical. Right-clicking a line number in Chrome DevTools lets me add a conditional breakpoint:

// Right-click the line, add condition: item.price < 0
for (const item of items) {
  total += item.price * item.quantity;
}

Execution only pauses when item.price < 0 evaluates to true, saving me from manually stepping through hundreds of harmless iterations.

Logpoints are similar but log a message to the console instead of pausing — useful when I want visibility without interrupting execution flow.

Debugging Asynchronous Code

Async bugs are some of the hardest to track down because the call stack at the point of failure often doesn’t show where the async operation originally started. Modern DevTools address this with async stack traces, showing the full chain even across await boundaries and setTimeout calls:

async function loadUserData(id) {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error(`Failed to load user ${id}`); // stack trace shows the full async chain
  }
  return response.json();
}

I also make heavy use of the Network panel alongside the Sources panel — pairing a failed request with the exact line of code that triggered it makes async debugging far more tractable.

Debugging Node.js Applications

For server-side code, I run Node with the --inspect flag and connect Chrome DevTools directly to the process:

node --inspect-brk index.js

The --inspect-brk variant pauses execution on the very first line, giving me time to open chrome://inspect in Chrome and attach before any code runs. This is especially useful for debugging startup logic or environment configuration issues.

I also use the built-in Node debugger directly from the terminal when a full DevTools session isn’t practical:

node inspect index.js
debug> next
debug> repl   # drop into a REPL at the current breakpoint to inspect variables

Error Objects and Stack Traces

Understanding the anatomy of a JavaScript Error object makes reading stack traces far more useful:

try {
  JSON.parse('{invalid json}');
} catch (error) {
  console.log(error.name);    // Output: "SyntaxError"
  console.log(error.message); // Output: "Unexpected token i in JSON at position 1"
  console.log(error.stack);   // Full stack trace as a string
}

I always create custom error classes for domain-specific errors, which makes catching and handling them more precise:

class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = 'ValidationError';
    this.field = field;
  }
}

function validateEmail(email) {
  if (!email.includes('@')) {
    throw new ValidationError('Invalid email format', 'email');
  }
}

try {
  validateEmail('not-an-email');
} catch (error) {
  if (error instanceof ValidationError) {
    console.log(`Validation failed on field: ${error.field}`);
  } else {
    throw error; // re-throw anything unexpected
  }
}

Debugging Memory Issues

When I suspect a memory leak, I use Chrome DevTools’ Memory panel to take heap snapshots at two points in time — before and after a suspected leaking interaction — then use the “Comparison” view to see which object types grew unexpectedly:

// A common leak pattern: accumulating references in a global array
const cache = [];

function processRequest(data) {
  cache.push(data); // never cleared — grows indefinitely
}

Spotting a steadily climbing “Retained Size” for a specific object type in consecutive heap snapshots is usually the clearest sign of a genuine leak, as opposed to normal, temporary memory growth that gets garbage collected.

Using the Call Stack and Scope Panel

When paused at a breakpoint, the Call Stack panel in DevTools shows the full chain of function calls that led to the current point, and clicking any frame lets me inspect that frame’s local variables in the Scope panel. This is invaluable for understanding why a function was called with unexpected arguments, not just that it was.

Linting and Static Analysis as Preventive Debugging

Some of the best debugging happens before the code ever runs, through tools like ESLint catching likely bugs statically:

// eslint rule "no-unused-vars" catches this before runtime
function calculate(a, b) {
  const total = a + b;
  return a; // bug: forgot to return `total`
}
// .eslintrc.json
{
  "extends": "eslint:recommended",
  "rules": {
    "no-unused-vars": "warn",
    "eqeqeq": "error",
    "no-console": "warn"
  }
}

I treat a clean lint pass as a prerequisite for debugging anything else — many “mysterious” bugs turn out to be simple issues like accidental == instead of ===, or a missing return.

Source Maps for Debugging Minified Code

In production, JavaScript is usually minified and bundled, making stack traces unreadable without source maps:

// webpack.config.js
module.exports = {
  devtool: 'source-map', // generates a full source map for accurate stack traces
};

With source maps enabled and uploaded alongside your build (or referenced correctly), DevTools automatically maps minified code back to your original source files and line numbers, even in production error reports.

Common Mistakes

MistakeConsequenceFix
Relying only on console.logSlow, imprecise debuggingUse breakpoints and the Scope panel
Leaving debugger; statements in committed codeUnexpected pauses for other developers/usersRemove before committing; lint for it
No source maps in productionUnreadable minified stack tracesEnable and deploy source maps
Ignoring async stack tracesLosing the “how did we get here” contextUse DevTools’ async stack trace support
Comparing objects with ==Silent type coercion bugsUse === and enable eqeqeq lint rule

FAQs

Is console.log ever the right tool? Yes — for quick, throwaway checks it’s still fast and convenient. But for anything requiring understanding of program state over time or across async boundaries, breakpoints are far more powerful.

How do I debug a bug that only happens in production? I rely on error tracking tools (like Sentry) that capture the full stack trace, breadcrumbs, and environment details automatically, combined with source maps so the trace is human-readable.

What’s the difference between a conditional breakpoint and a logpoint? A conditional breakpoint pauses execution when a condition is true; a logpoint logs a message to the console without pausing, which is useful when you don’t want to interrupt the running application.

Summary and Key Takeaways

Debugging got dramatically faster for me once I moved past console.log as my primary tool:

References

Exit mobile version