Browser DevTools: Advanced Debugging Techniques

Introduction

Have you ever spent hours trapped by a bug that disappears as soon as you try to inspect it? The feeling of playing cat and mouse with the browser is more common than it seems, and most of the time the issue isn't the code itself but rather how we analyze it. When I started using DevTools as a "notebook" instead of just a simple inspection panel, my productivity skyrocketed and my frustration decreased drastically. In this article, I share the advanced techniques that helped me turn hours of bug hunting into minutes of precise diagnosis. Prepare your coffee, open Chrome (or Firefox; Edge has almost the same functionalities), and let's zoom in on the layers that really matter.

Blisk browser DevTools: panels opened for iPhone 12 and iPad Pro 11.
Blisk browser DevTools: panels opened for iPhone 12 and iPad Pro 11. — Image: Wikipedia-blisk-devtools-iphone-12-and-ipad-pro-11-2021.png by And85rew — CC BY-SA 4.0 · via Wikimedia Commons

Performance Profiler

The first thing that usually catches attention when the application starts to stutter is the response time. The Performance panel (or Profiler in browsers that still use the old name) allows you to record the execution of the page and analyze each frame, each function call, and each layout event. In practice, I usually follow three steps:

  1. Record a real scenario – no "random clicking". Reproduce the sequence that the end user would perform, such as opening a modal, scrolling the page, or submitting a form.
  2. Identify "long tasks" – Chrome highlights sections that exceed 50 ms. Click on them to see the call stack.
  3. Isolate the culprit – use the "Bottom-Up" tool to discover which function consumes the most total time.

A classic example is a rendering loop that tries to update 10,000 rows of a table every frame. The code below demonstrates how the simple addition of requestAnimationFrame can transform "freezing code" into something fluid:

function renderRows(data) {
  const tbody = document.querySelector('tbody');
  tbody.innerHTML = '';
  data.forEach(row => {
    const tr = document.createElement('tr');
    row.forEach(cell => {
      const td = document.createElement('td');
      td.textContent = cell;
      tr.appendChild(td);
    });
    tbody.appendChild(tr);
  });
}

// Problematic version
function renderAllAtOnce(data) {
  console.time('render');
  renderRows(data);
  console.timeEnd('render');
}

// Optimized version
function renderChunked(data) {
  let index = 0;
  const chunkSize = 200;

  function draw() {
    const slice = data.slice(index, index + chunkSize);
    renderRows(slice);
    index += chunkSize;
    if (index < data.length) {
      requestAnimationFrame(draw);
    }
  }

  console.time('renderChunked');
  draw();
  console.timeEnd('renderChunked');
}

// Usage
const massiveData = Array.from({ length: 10000 }, () =>
  Array.from({ length: 5 }, () => Math.random().toFixed(2))
);
renderAllAtOnce(massiveData); // freezes
renderChunked(massiveData);   // smooth

In the Performance panel, the difference appears as a single peak in the first version and as several smaller peaks in the second. The tool also shows the time spent on "recalculate style" and "layout," helping to decide if it's worth using transform instead of top/left.

Practical tip: if your code contains setTimeout(..., 0) or Promise.resolve().then(...), the profiler groups these calls as "Task." Check if you are creating a chain of micro-tasks that prevents the main thread from being released.

Memory Snapshots

Memory can be the silent villain of an application that seems "heavy" after a few minutes of use. The Memory panel offers three types of snapshots: Heap snapshot, Allocation instrumentation on timeline, and Allocation sampling. In my experience, the combination of an initial snapshot and another after reproducing the problematic flow quickly reveals where objects remain alive unnecessarily.

A classic case I encountered was a scroll listener that was never removed. Each call created a new configuration object, and the GC couldn't collect it because the listener still referenced the object. The following code reproduces the problem and shows how to fix it:

// Problematic code
function attachScroll() {
  const config = { threshold: 0.5 };
  window.addEventListener('scroll', () => {
    // using config here
    console.log('scroll', config.threshold);
  });
}

// Each call creates a new config that never leaves memory
for (let i = 0; i < 100; i++) {
  attachScroll();
}

After opening Memory, I took a snapshot, executed the loop above, and took another snapshot. The difference showed thousands of Object objects with the threshold property. The solution was to separate the listener from the creation of objects:

// Fixed code
const sharedConfig = { threshold: 0.5 };
function onScroll() {
  console.log('scroll', sharedConfig.threshold);
}
window.addEventListener('scroll', onScroll);

Now the snapshot does not grow, and the RAM consumption stabilizes. Another useful tool is the Allocation timeline, which displays the allocation rate in real-time. If you notice allocation spikes when opening a modal, it may indicate that some component is creating unnecessary objects on each render.

Practical tip: when inspecting a snapshot, use the filter bar to search for class names or for “(system)” and “(detached)”. This helps exclude internal browser objects and focus on what really belongs to your code.

Network Waterfall

The network layer is usually the first thing I look at when the page is slow to load. The Network panel displays a "waterfall" that shows the sequence of requests, the wait time (TTFB), the download, and the processing. One thing I've noticed is that even if the file sizes seem small, the number of requests can be the bottleneck.

Imagine an application that loads 30 small images via <img src="...">. Each image generates an HTTP/2 connection, but the cost of the handshake can still be relevant on mobile devices. The solution I adopted was to group images into sprites or use srcset with responsive images. The example below demonstrates how to use fetch with keepalive to ensure telemetry requests do not interfere with navigation:

function sendTelemetry(data) {
  navigator.sendBeacon('/api/telemetry', JSON.stringify(data));
}

// Alternative with fetch and keepalive (supports modern browsers)
async function sendTelemetryFetch(data) {
  await fetch('/api/telemetry', {
    method: 'POST',
    body: JSON.stringify(data),
    headers: { 'Content-Type': 'application/json' },
    keepalive: true,
  });
}

In the Waterfall, sendBeacon appears as “(pending)” and does not block rendering. When I tested the same flow without keepalive, the browser kept the connection open until the user closed the tab, which caused a visible "blocking" in the "Waiting" column.

Another point that often goes unnoticed is caching. In the Network panel, enable the "Disable cache" option only when testing code changes. In practice, I keep caching enabled in most sessions to observe the real user behavior. If a request is always returning 200 OK instead of 304 Not Modified, it may be a sign that the Cache-Control header is configured inadequately.

Post glossary

event
A relevant system occurrence (click, order, message) that triggers asynchronous actions, the basis of event-driven architectures.
ram
Random Access Memory: fast volatile memory used for data in use while programs run.
http
Hypertext Transfer Protocol: the communication protocol used between browsers and web servers.
cache
Temporary storage of already-computed or fetched data to speed up future access and reduce server load.
Ismael Douglas

About the author

Ismael Douglas

Full Stack developer passionate about building fast, well-crafted web products. I write about what I learn along the way — code, infra and best practices.

Check out my portfolio →