JavaScript Debugging Tips Every Developer Should Know

Written By  Crosscheck Team

Content Team

June 30, 2025 12 minutes

JavaScript Debugging Tips Every Developer Should Know

JavaScript Debugging Tips Every Developer Should Know

Every JavaScript developer debugs. The question is whether you're doing it efficiently or spending hours staring at a wall of console.log statements hoping something surfaces. Modern browsers give you a remarkably powerful suite of debugging tools, and knowing how to use them properly turns hours of guesswork into minutes of targeted investigation.

This guide covers the practical JavaScript debugging techniques that make the biggest difference — from underused console methods to breakpoints, the debugger statement, source maps, async-specific patterns, and the common bugs that trip up even experienced developers.


Go Beyond console.log

console.log is everyone's first debugging instinct. It works, but it's the bluntest tool in the console API. Once you know the full range of what console can do, you'll rarely need raw log for anything beyond quick, throwaway checks.

console.table

When you're working with arrays of objects — API responses, database results, state snapshots — console.table renders them as a formatted table instead of a collapsed object tree:

const users = [
  { id: 1, name: 'Alice', role: 'admin' },
  { id: 2, name: 'Bob', role: 'editor' },
  { id: 3, name: 'Carol', role: 'viewer' },
];
console.table(users);

Instead of clicking through three nested objects, you get columns for id, name, and role with each user on its own row. For data-heavy debugging this alone saves significant time.

You can also pass a second argument — an array of column names — to show only the fields you care about:

console.table(users, ['name', 'role']);

console.group and console.groupCollapsed

When your code path touches many components or functions, a flat stream of log messages becomes hard to follow. console.group lets you nest related messages under a collapsible label:

console.group('Order processing');
console.log('Validating cart...');
console.log('Calculating totals...');
console.group('Payment');
console.log('Charging card...');
console.log('Payment confirmed');
console.groupEnd();
console.log('Sending confirmation email...');
console.groupEnd();

This creates a tree structure in the console that mirrors your code's logical flow. console.groupCollapsed starts the group in a collapsed state — useful when you want the structure available without it dominating the console output.

console.time and console.timeEnd

For quick performance measurements without opening the Performance panel, wrap a block of code with console.time and console.timeEnd:

console.time('data transform');
const result = expensiveTransform(rawData);
console.timeEnd('data transform');
// Outputs: data transform: 142.35ms

The label you pass becomes the timer's identifier — you can run multiple named timers simultaneously. This is particularly useful for comparing two implementations of the same function or tracking how long a specific API call takes in practice.

console.warn, console.error, and console.assert

Use console.warn for conditions that are unexpected but not fatal, and console.error for actual errors. Both produce colored, visually distinct output that's easy to spot and can be filtered in the console panel.

console.assert is often overlooked but extremely useful — it logs an error only when a condition is false:

console.assert(user.id !== undefined, 'User object is missing an id', user);

This acts like a lightweight inline test: if the assertion holds, nothing is logged. If it fails, you get the message and the object. It's faster than wrapping every check in an if statement and leaves less noise in the code than a permanent log.


Use Breakpoints Instead of console.log

Breakpoints are the most powerful debugging tool available in browser DevTools, and most developers underuse them. The key insight is that a breakpoint pauses execution at an exact point in your running code and lets you inspect every variable in scope, step through subsequent lines, and evaluate arbitrary expressions — all without modifying your source.

Setting Breakpoints in DevTools

Open the Sources panel in Chrome DevTools (Cmd+Option+I on Mac, F12 on Windows/Linux, then click Sources). Use Cmd+P / Ctrl+P to open a file by name. Click any line number in the gutter to set a breakpoint — a blue marker will appear.

When your code reaches that line, execution pauses. The Scope panel on the right shows every local, closure, and global variable currently in scope. The Call Stack panel shows exactly how execution arrived at this point, which is invaluable for tracing unexpected code paths.

Conditional Breakpoints

Plain breakpoints pause on every execution, which gets tedious inside loops or frequently-called functions. Right-click a line number and select Add conditional breakpoint to only pause when a specific expression is true:

items[i].status === 'failed'

This pauses execution only on the iteration where something actually went wrong, letting you skip thousands of normal passes and land directly at the problem.

Logpoints

Logpoints are a less-known feature that bridges the gap between breakpoints and console.log. Right-click a line number and select Add logpoint, then enter an expression. When execution hits that line, the expression is logged to the console — but execution is not paused. This gives you console.log-style output without touching your source code and without the commit noise of temporary debug statements.

Event Listener Breakpoints

When you're not sure which code fires in response to a user action, the Event Listener Breakpoints panel (in the Sources sidebar) lets you pause on any browser event — click, input, submit, scroll, and hundreds more. Check the relevant event type, perform the action in the browser, and DevTools will pause at the exact handler that fired.


The debugger Statement

The debugger statement is a line of JavaScript that, when DevTools is open, pauses execution exactly as a breakpoint would:

function processPayment(cart, paymentMethod) {
  debugger; // Execution pauses here when DevTools is open
  const total = calculateTotal(cart);
  return charge(total, paymentMethod);
}

This is useful when a breakpoint in the Sources panel is hard to set — for example, in dynamically generated code, in a module deep in node_modules, or when you want to commit a temporary pause point and move it around with your editor rather than clicking in DevTools.

When DevTools is closed, debugger statements are silently ignored, so they won't affect production users. That said, they should still be treated as temporary — don't commit them to your main branch. Most linting setups (ESLint's no-debugger rule) will flag them automatically.


Source Maps

Modern JavaScript is almost never deployed as-written. Build tools minify, bundle, transpile (TypeScript, Babel), and tree-shake your code before it reaches the browser. The result is that when an error points to main.abc123.min.js:1:48291, that location is meaningless relative to your actual source files.

Source maps solve this by providing a mapping between the compiled output and your original source. When source maps are enabled:

  • Stack traces in the console reference your original filenames and line numbers
  • The Sources panel shows your original TypeScript or JSX files, not minified bundles
  • Breakpoints set in your original files work correctly on compiled code
  • Error monitoring tools (Sentry, Datadog, etc.) can display readable stack traces

Enabling Source Maps

Most build tools generate source maps with a configuration option:

// webpack.config.js
module.exports = {
  devtool: 'source-map', // Full source maps for production
  // or
  devtool: 'eval-source-map', // Faster rebuild, for development only
};

Vite, Rollup, esbuild, and Parcel all have equivalent options. For TypeScript, set "sourceMap": true in tsconfig.json.

In DevTools, source maps are used automatically when present. You can verify they're working by checking whether the Sources panel shows your original files or minified output.

Security Considerations

Publishing source maps to production exposes your original source code to anyone with DevTools. If that's a concern, generate them but host them on an internal server or upload them only to your error monitoring service. Most error monitoring tools support uploading source maps separately so stack traces are de-minified in your dashboard without the maps being publicly accessible.


Debugging Async Code

Async bugs are among the hardest to reproduce and trace because the call stack at the point of failure doesn't show you where the async operation was initiated. A promise rejection or an await that resolves to an unexpected value can appear several call frames removed from the code that triggered it.

Async Stack Traces

Chrome DevTools has async stack trace support enabled by default. When you pause on an exception or a breakpoint inside an async function, the Call Stack panel shows not just the current synchronous call stack but the asynchronous frames leading up to it — across await boundaries, Promise.then chains, and setTimeout callbacks.

Look for the "Async" separator in the call stack. The frames below it show where the asynchronous operation was started, which is often where the logic error lives.

Pause on Exceptions

In the Sources panel, the breakpoint controls at the top include a pause-on-exceptions button (the hexagonal icon). Enable Pause on caught exceptions to have DevTools pause at the exact line where any exception is thrown — including ones that are caught and swallowed by a try/catch block. This is invaluable for finding errors that are being silently handled without being logged.

Tracking Promise Rejections

Unhandled promise rejections appear in the console as warnings, but they're easy to miss. Make it a habit to add a global handler during development:

window.addEventListener('unhandledrejection', (event) => {
  console.error('Unhandled promise rejection:', event.reason);
});

This surfaces every unhandled rejection explicitly, making it impossible to overlook a silent async failure. Remove or conditionally apply this in production based on your error monitoring setup.

async/await vs Promise Chains

Async code written with async/await is significantly easier to debug than .then() chains because the stack traces are more readable and breakpoints behave predictably — you can step through await expressions line by line. If you're maintaining a codebase with heavy Promise chain usage and finding it hard to debug, refactoring to async/await is often worth it on debugging ergonomics alone.


Common JavaScript Bugs and How to Catch Them

Type Coercion Surprises

JavaScript's loose equality and implicit type coercion produce bugs that are genuinely hard to spot without understanding the rules. Using == instead of === is the most common source:

0 == false   // true
'' == false  // true
null == undefined  // true
'5' == 5     // true

Strict equality (===) checks both value and type, eliminating the entire class of coercion bugs. ESLint's eqeqeq rule enforces this automatically. Enable it and treat the output as authoritative.

undefined vs null vs Missing Properties

A common source of TypeError: Cannot read properties of undefined is accessing a property on a value that was expected to exist but doesn't. Optional chaining (?.) and nullish coalescing (??) handle this cleanly:

// Before: throws if user or user.address is undefined
const city = user.address.city;

// After: returns undefined without throwing
const city = user?.address?.city;

// With a fallback
const city = user?.address?.city ?? 'Unknown';

When these errors appear in production, the stack trace will point to the line — use that line in the Sources panel, set a breakpoint, and inspect the value of each property in the chain to find where it becomes undefined.

Closures and Stale Variable Captures

A classic async/loop bug: a variable captured in a closure doesn't have the value you expect when the callback finally runs:

// Bug: all callbacks log the same value (the final value of i)
for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100);
}

// Fix: use let (block-scoped) or an IIFE to capture each value
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), 100);
}

This pattern appears in event listeners, Promises, and any asynchronous callback. The fix is almost always replacing var with let or const, or restructuring to avoid capturing a mutable variable.

this Binding

this in JavaScript doesn't refer to the object you might expect, especially in callbacks and event handlers. Arrow functions don't have their own this binding — they inherit it from the enclosing scope — which is why they're often the right choice inside methods:

class Timer {
  constructor() {
    this.count = 0;
  }

  start() {
    // Bug: 'this' is undefined or window inside a regular function callback
    setInterval(function() { this.count++; }, 1000);

    // Fix: arrow function captures 'this' from start()
    setInterval(() => { this.count++; }, 1000);
  }
}

When a this reference produces undefined or behaves unexpectedly, set a breakpoint at the line and inspect this in the Scope panel — it will show you exactly what this is bound to at that point.

Mutating Objects You Don't Own

JavaScript objects and arrays are passed by reference. Mutating an object in one place silently affects every other part of the code holding a reference to the same object:

function applyDiscount(cart) {
  cart.total = cart.total * 0.9; // Mutates the original cart object
  return cart;
}

This is a frequent source of bugs in state management. Use the spread operator or structuredClone to work with copies when mutation isn't intended:

function applyDiscount(cart) {
  return { ...cart, total: cart.total * 0.9 }; // Returns a new object
}

To catch this class of bug, set a watchpoint on the object's property in DevTools (right-click a property in the Scope panel and select Break on property write). DevTools will pause whenever that property is modified, immediately surfacing unexpected mutations.


A Few More Techniques Worth Knowing

Copy as fetch: In the Network tab, right-click any request and select Copy > Copy as fetch. This gives you a fetch() call with the exact URL, headers, and body from the original request — paste it directly into the console to replay it, modify parameters, and test responses without touching your application code.

Live expressions: The DevTools console has a Create live expression button (the eye icon) that lets you pin any JavaScript expression and have it continuously evaluated in real time. Pin document.activeElement to track focus, or store.getState().currentUser to watch your Redux state update as you interact with the page.

Blackboxing: When stepping through code, third-party libraries and frameworks add noise to the call stack. Right-click a script in the Call Stack panel and select Add script to ignore list (formerly Blackbox). DevTools will skip over that script when stepping, so you land directly in your application code rather than wading through library internals.

Network request blocking: In the Network panel, right-click any request and select Block request URL to simulate that resource being unavailable. This is a quick way to test how your application handles a failed API call or a missing third-party script without modifying your code or network configuration.


Capture the Full Picture When You Find a Bug

These techniques will help you find bugs faster. But finding a bug is only half the problem — the other half is documenting it well enough that it actually gets fixed. A bug report that says "it broke" with no context sends developers back into their own debugging session from scratch.

Effective bug documentation means capturing the console errors, the network requests, the exact sequence of steps taken, and ideally a recording of the failure happening. Assembling all of that manually — screenshots, copied console output, network logs — is tedious and easy to do incompletely.

Crosscheck captures it all automatically. When you find a bug, a single click records the screenshot, screen recording, every console log and error, and all network requests from the session — bundled into one shareable report that gives developers everything they need to reproduce and fix the issue without asking follow-up questions.

For teams where bugs regularly get stuck in back-and-forth over missing context, Crosscheck eliminates that cycle. The debugging skills covered in this article tell you what to look for — Crosscheck makes sure that information is always captured and always attached to the report.

Related Articles

Contact us
to find out how this model can streamline your business!
Crosscheck Logo
Crosscheck Logo
Crosscheck Logo

Speed up bug reporting by 50% and
make it twice as effortless.

Overall rating: 5/5