Mert Özen Further With Every Line
Frontend JavaScript Advanced — #10

JavaScript Performance Tips: Debounce, Throttle and Layout Thrashing

Mert Özen Nov 3, 2026 10 min 5 views
JavaScript Performance Tips: Debounce, Throttle and Layout Thrashing

We learn to measure with performance.now, mark and measure, write our own debounce and throttle, and cover requestAnimationFrame, layout thrashing, lazy loading and the DevTools Performance tab.

Version note: The code in this post targets ES2026 and current browsers (Chrome, Edge, Firefox, Safari).

Predict First

Below is a small, ten-line helper function. Let's not say what it's for just yet. What does this code print to the console: how many lines, and which letters?

function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

const log = debounce(console.log, 200);
log("a");
log("b");
log("c");
setTimeout(() => log("d"), 300);

Make a guess before you keep reading. There are four calls, but are you sure there will be four lines of output? We'll get to the answer while building up the logic of debounce.

Measure First: performance.now, mark and measure

The first rule of performance work is not to optimize on a hunch. Changing code because "that loop feels slow" is like shooting arrows in the dark; more often than not, the slowness is somewhere you never expected. So we measure first.

performance.now() gives you the time elapsed since the page opened, in milliseconds, with decimals. It has two advantages over Date.now(): it's more precise, and it's monotonic, meaning it never goes backwards even if the computer's clock is adjusted. It's the right tool for measuring durations. For more organized measurements there are mark and measure. Paste this into your browser's console and try it:

performance.mark("sort-start");

const numbers = Array.from({ length: 200_000 }, () => Math.random());
numbers.toSorted((a, b) => a - b);

performance.mark("sort-end");

const result = performance.measure("sorting", "sort-start", "sort-end");
console.log(`${result.name}: ${result.duration.toFixed(1)} ms`);

mark places a named marker on the timeline, and measure computes the time between two markers and returns the result as an object. A nice bonus is that these measurements show up on their own track in a DevTools Performance recording. Two notes: for security reasons, browsers slightly reduce the precision of these timers, so don't trust differences of a few microseconds. Also, the first run is usually slower because the engine hasn't optimized the code yet. Run the work a few times and look at the average.

Debounce: Run Once Things Calm Down

Think of an elevator door. Someone steps in, the door waits. Someone else steps in, the door waits again. The door only closes once nobody has come for a few seconds. That's exactly what debounce does: it swallows calls that arrive in quick succession and only runs the last one once things have calmed down.

Look at the opening code. Every log call first cancels the pending timer, then sets a new one. "a", "b" and "c" arrive at the same moment, so the first two timers are cancelled before they can run; after 200 ms only c is printed. "d", arriving at the 300 ms mark, comes after things have calmed down, so it gets its own turn and d is printed around the 500 ms mark. So four calls, two lines of output: c and d.

The most typical use is a search box. Instead of sending a request to the server for every letter while the user types "javascript", you send a single request when they stop typing. Save this as an HTML file and try it:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Search with debounce</title>
</head>
<body>
  <input id="search" placeholder="Type something...">
  <p id="status"></p>

  <script>
    function debounce(fn, ms) {
      let timer;
      return (...args) => {
        clearTimeout(timer);
        timer = setTimeout(() => fn(...args), ms);
      };
    }

    let keyCount = 0;
    let searchCount = 0;
    const status = document.querySelector("#status");

    function update() {
      status.textContent = `${keyCount} keys, ${searchCount} searches`;
    }

    const search = debounce((text) => {
      searchCount++;
      console.log("Searching:", text);
      update();
    }, 300);

    document.querySelector("#search").addEventListener("input", (e) => {
      keyCount++;
      update();
      search(e.target.value);
    });
  </script>
</body>
</html>

Type a word quickly: the key count will be in the dozens, the search count will be one. The same idea is used to recalculate once a window resize has finished, or to autosave a form.

Throttle: Run at a Steady Interval

Debounce is sometimes too patient. If the user scrolls the page non-stop for ten seconds, a debounced function doesn't run at all for those ten seconds. If you want to update something on screen while scrolling, you need something else: throttle. Think of it like a metro schedule. No matter how many people pile up on the platform, the train leaves every five minutes. Throttle runs a function at most once per given interval.

function throttle(fn, ms) {
  let last = 0;
  let timer = null;
  let lastArgs;

  return (...args) => {
    lastArgs = args;
    const remaining = ms - (performance.now() - last);

    if (remaining <= 0) {
      clearTimeout(timer);
      timer = null;
      last = performance.now();
      fn(...args);
    } else if (!timer) {
      timer = setTimeout(() => {
        timer = null;
        last = performance.now();
        fn(...lastArgs);
      }, remaining);
    }
  };
}

const save = throttle((x) => console.log("ran:", x), 500);
let i = 0;
const ticker = setInterval(() => save(++i), 50);
setTimeout(() => clearInterval(ticker), 2000);

We made twenty calls per second for two seconds, so forty calls. In the console you'll see about five lines. The first call runs right away. For calls that arrive before the interval is up, a single timer is set, and when the interval ends it runs with the most recent arguments. That way you never miss the final value. In short: debounce means "once, when it stops", throttle means "at regular intervals while it keeps going".

requestAnimationFrame: In Step with the Screen

When we covered the event loop earlier, we saw where requestAnimationFrame fits: its callback runs right before the browser draws the next frame. If the screen refreshes 60 times per second, that's 60 times per second; on a 120 Hz screen, 120 times. The reason to use it instead of setInterval(fn, 16) for visual updates is this: a timer doesn't know the screen's rhythm, so sometimes it runs twice in one frame, sometimes not at all, and you get jank. On top of that, requestAnimationFrame pauses while the tab is in the background and doesn't waste battery.

A reading progress bar tied to scrolling is a nice example that brings both ideas together. The scroll event can fire several times within a single frame; we'll do only one update per frame:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Reading progress</title>
  <style>
    #bar { position: fixed; top: 0; left: 0; height: 6px; width: 0; background: tomato; }
    main { height: 400vh; padding: 20px; }
  </style>
</head>
<body>
  <div id="bar"></div>
  <main>Scroll down...</main>

  <script>
    const bar = document.querySelector("#bar");
    let scheduled = false;

    function updateBar() {
      scheduled = false;
      const total = document.documentElement.scrollHeight - innerHeight;
      bar.style.width = `${(scrollY / total) * 100}%`;
    }

    window.addEventListener("scroll", () => {
      if (scheduled) return;
      scheduled = true;
      requestAnimationFrame(updateBar);
    }, { passive: true });
  </script>
</body>
</html>

The scheduled flag makes sure we don't ask for another update if one is already planned for this frame. It's a throttle locked to the screen's rhythm. The passive: true we saw when covering events promises the browser that "this listener won't block scrolling", so scrolling flows without waiting for us.

Layout Thrashing: Read, Write, Read, Write...

We had a first look at reflow and repaint earlier. The browser is lazy, and that's a good thing: when you change an element's style, it doesn't recalculate the page layout right away; it marks it as "dirty" and calculates everything at once before the next paint. But if you then read a measurement like offsetWidth, getBoundingClientRect() or scrollTop, the browser has no up-to-date answer. It has to make you wait and calculate the layout right then, synchronously. This is called a forced reflow.

Do this inside a loop, reading and then writing on every turn, and the browser recalculates the layout on every turn. That's layout thrashing. It's like a tailor who re-measures the fabric after every stitch. See the difference for yourself:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Layout thrashing</title>
  <style>
    #box-container { display: flex; flex-wrap: wrap; gap: 2px; }
    .box { width: 40px; height: 20px; background: steelblue; }
  </style>
</head>
<body>
  <button id="bad">Mixed reads and writes</button>
  <button id="good">Read first, then write</button>
  <p id="result"></p>
  <div id="box-container"></div>

  <script>
    const container = document.querySelector("#box-container");
    for (let i = 0; i < 1500; i++) {
      const box = document.createElement("div");
      box.className = "box";
      container.append(box);
    }
    const boxes = [...container.children];

    function bad() {
      for (const box of boxes) {
        const width = box.offsetWidth;
        box.style.width = `${(width % 60) + 1}px`;
      }
    }

    function good() {
      const widths = boxes.map((b) => b.offsetWidth);
      boxes.forEach((b, i) => {
        b.style.width = `${(widths[i] % 60) + 1}px`;
      });
    }

    function measure(fn) {
      const t0 = performance.now();
      fn();
      document.body.offsetHeight; // include the final layout in the measurement
      document.querySelector("#result").textContent =
        `${fn.name}: ${(performance.now() - t0).toFixed(1)} ms`;
    }

    document.querySelector("#bad").addEventListener("click", () => measure(bad));
    document.querySelector("#good").addEventListener("click", () => measure(good));
  </script>
</body>
</html>

The two functions produce the same result; the only difference is the order of operations. In my test, the mixed version took hundreds of milliseconds while the other finished in a few; your numbers may differ, but the gap will still be obvious. The rule is simple: do all the reads first, then all the writes. When reads and writes really have to be interleaved, deferring the writes to the next frame with requestAnimationFrame also helps.

Lazy Loading: Load It When You Need It

The fastest code is code that never gets downloaded. Loading images the user doesn't see and panels they never open slows down the first load. For images and iframes it takes a single attribute: <img src="large.jpg" loading="lazy" width="800" height="600" alt="...">. The browser doesn't download the image until it gets close to the viewport. This feature has been Baseline widely available since June 2026. Don't forget width and height; otherwise the page jumps down when the image arrives.

The same idea applies to code. With the dynamic import() we saw earlier, you load a module only when it's needed. Because it uses modules, this example runs on a local server that serves both files from the same folder. index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Load when needed</title>
</head>
<body>
  <button id="report">Show report</button>
  <script type="module">
    document.querySelector("#report").addEventListener("click", async () => {
      const { drawReport } = await import("./report.js");
      drawReport(document.body);
    });
  </script>
</body>
</html>

And report.js in the same folder:

export function drawReport(target) {
  const p = document.createElement("p");
  p.textContent = "The report module has just loaded.";
  target.append(p);
}

If you look at the Network tab in DevTools, you'll see that report.js isn't requested until you click the button. Detecting yourself when an element enters the viewport and loading based on that is something we'll cover in an upcoming post on observer APIs.

A First Look at the DevTools Performance Tab

performance.now() tells you how long something took; the Performance tab shows what the browser was doing during that time. Open the layout thrashing page, switch to the Performance tab in DevTools and press the record button. Click "Mixed reads and writes" a few times, then stop the recording.

The Main track in the middle is the main thread's timeline. Stacked blocks show the call stack: the event on top, the functions it called below. Yellow blocks are JavaScript running, purple blocks are style and layout calculation, green ones are painting. In the mixed version you'll see countless purple Layout blocks squeezed inside the yellow block; click one and DevTools shows a forced reflow warning and takes you to the line in your code. Tasks longer than 50 ms are marked as "long tasks" with a red triangle and a red-striped tail; these are the moments when the user feels the page stutter. The Bottom-Up tab below ranks which functions ate up the most time.

Two small tips. With CPU throttling in the panel's settings, you can slow down the processor and try your page as if it were on a weak phone. And the performance.mark and measure calls in your code show up on a separate track in the recording. Firefox has a similar profiler, and Safari has the Timelines tab.

Common Pitfalls

Recreating the debounce on every event. If you write input.addEventListener("input", (e) => debounce(search, 300)(e.target.value)), every key press produces a brand-new debounced function, meaning a brand-new timer; none of them cancels the others. Create the debounced function once and always call that one.

Trusting a single measurement. The first run is the one where the engine hasn't optimized the code yet. The page also gets a bit slower while DevTools is open. Measure several times and compare both versions under the same conditions.

Measuring durations with Date.now(). The value can jump while the system clock is being synchronized, and your measurement can even come out negative. Use performance.now() for durations.

Treating requestAnimationFrame as a magic wand. Putting heavy work inside rAF doesn't make it lighter; it just delays the frame. A calculation that takes 200 ms locks the main thread for 200 ms no matter where you put it. Work like that needs to be split into pieces or moved off the main thread entirely.

A Small Experiment

★☆☆ Compute the sum of an array with a million elements, first with a for loop and then with reduce. Measure both with performance.mark and measure, and find the results on the timings track of a Performance recording too.

★★☆ Predict the output: using the throttle from this post, define const t = throttle(console.log, 500); and call t("a") immediately, t("b") after 100 ms, t("c") after 200 ms and t("d") after 600 ms. Which letters are printed, and roughly when? Set it up with setTimeout and try it.

★★★ Rewrite the bad function on the layout thrashing page so that it does the reads in the same turn and batches the writes inside requestAnimationFrame. Confirm in a Performance recording that the forced reflow warnings are gone.

All the techniques in this post come down to doing less work on the main thread or scheduling it more cleverly. But some work is heavy no matter how well you plan it: processing a large dataset, filtering an image, a complex calculation. In the next post we'll move that kind of work off the main thread entirely and run it in the background: Web Workers.