Back to Blog
PerformanceJune 28, 20264 min read

Web Performance: Stop Main-Thread Blocking with AbortController

Master web performance by using AbortController to cancel stale fetch requests. Learn how to stop main-thread blocking and improve your Interaction to Next Paint.

web performancejavascriptreactfetch apiabortcontrollermain-threadPerformanceWeb VitalsFrontend

We’ve all been there: a user clicks a filter, changes their mind, clicks another, and suddenly the UI feels like it’s wading through molasses. If you aren't canceling your stale network requests, you’re likely contributing to main-thread blocking without realizing it. I recently spent about two days debugging a dashboard where the browser was struggling to process incoming JSON payloads from three different "in-flight" requests, causing the Interaction to Next Paint (INP) to skyrocket.

The browser's main thread is a busy place. It handles layout, style calculations, and JavaScript execution. When you trigger multiple fetch requests and don't clean up the ones that are no longer relevant, the browser still has to parse and process those responses when they arrive. That's a massive waste of resources.

The Problem: Ignoring Stale Requests

When a user initiates a request, we often just wait for the promise to resolve. If the user navigates away or updates their search criteria, the original request keeps running. In a complex application, these "ghost" requests can lead to race conditions where the UI updates with old data, or worse, the browser stays busy parsing large objects while the user is trying to click something else.

I initially tried to solve this by simply ignoring the result in the useEffect cleanup. I thought, "If I don't call setState, it won't matter." I was wrong. The network request still completes, the browser still receives the bytes, and the main thread still has to execute the json() parsing logic.

Implementing AbortController for Better Web Performance

The AbortController API is the standard way to tell the browser: "Stop what you're doing; I don't care about this request anymore." It effectively prevents the browser from processing the response, which saves precious CPU cycles.

Here is how I implemented it in a standard data-fetching hook:

JAVASCRIPT
import { useEffect, useState } from CE9178">'react';

function useDataFetch(url) {
  const [data, setData] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;

    fetch(url, { signal })
      .then((res) => res.json())
      .then((json) => setData(json))
      .catch((err) => {
        if (err.name === CE9178">'AbortError') {
          console.log(CE9178">'Request cancelled');
        } else {
          // Handle actual errors
        }
      });

    return () => controller.abort();
  }, [url]);

  return data;
}

By passing the signal to the fetch call, we bridge the gap between the application logic and the browser's underlying networking stack. When the component unmounts or the url changes, the abort() call triggers, and the browser drops the connection immediately.

Comparing Approaches to Data Fetching

When architecting these flows, it's helpful to understand why we choose specific patterns over others.

StrategyPerformance ImpactComplexityBest For
Standard FetchHigh (Blocking)LowSimple, single requests
AbortControllerLow (Efficient)MediumDynamic UI/Search inputs
React Query/SWRLow (Managed)LowLarge-scale state sync
Web WorkersVery LowHighHeavy data processing

If you're dealing with massive payloads, even cancelling the request might not be enough if the initial parsing is the bottleneck. In those cases, I've had success using Web Workers for JSON Parsing: Stop Blocking the Main Thread to offload the heavy lifting.

Why Interaction to Next Paint Matters

Your INP score is a direct reflection of how efficiently the main thread handles user input. Every millisecond spent parsing a JSON response from a request the user no longer needs is a millisecond that could have been used to update the UI.

When we talk about fetching data in a React component the right way, we have to account for the lifecycle of the request. It isn't just about initiating the fetch; it's about managing the lifecycle of that request so it doesn't outlive its usefulness.

Beyond the Component Level

If you're working in a modern stack like Next.js, the architecture changes slightly. You might be dealing with Next.js App Router data fetching: Avoiding Performance Waterfalls where you have to balance server-side efficiency with client-side interactivity. While AbortController is a client-side tool, the principle of not doing unnecessary work remains the same.

If you find yourself over-fetching, it might be time to look at Next.js request memoization: Stop over-fetching in Server Components to ensure your data pipeline stays lean.

Final Thoughts

The most important thing I learned while chasing these regressions is that the browser is a black box until you start profiling it. I spent way too long looking at my React code when the real issue was the sheer volume of data being parsed on the main thread.

AbortController isn't a silver bullet. If you have a massive API response that must be fetched, it won't magically make the network faster. But it will ensure that your users aren't paying the performance tax for data they'll never see. Next time you're building a feature that involves dynamic fetching, add the controller before you even finish the first fetch implementation. Your users will notice the difference.

Similar Posts