Tech Verse Logo
Enable dark mode
Resilient Polling Loops with React and AbortController

Resilient Polling Loops with React and AbortController

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

•4 min read

Polling data from a remote server inside a React component looks simple at first glance. You drop a setInterval inside a useEffect hook, fetch your endpoint, and update some state. The UI updates every few seconds. Everything seems fine until you open your browser's network tab. You'll notice overlapping requests, responses arriving out of order, and memory leaks piling up after a user unmounts the component or changes filters. That is the reality of naive client-side polling.

Building real-time features often pushes teams toward WebSockets. Polling remains a practical choice for many dashboards and metrics screens despite that push. The problem isn't polling itself. It is how we manage the asynchronous operations inside the lifecycle of a modern component. If you aren't careful with cleanup functions and signal cancellation, your app will waste bandwidth and throw state updates on unmounted nodes. You can read more about state management decisions in React useReducer: When useState Stops Scaling to see how complex states are best handled.

The Core Problem with setInterval in Effects

The naive approach to polling usually involves a timer callback that triggers a fetch request. Let's look at what that bad implementation looks like so we know what to avoid. It creates overlapping requests when network latency spikes.

import { useState, useEffect } from 'react';

export function BadPoller() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const interval = setInterval(async () => {
      const response = await fetch('/api/metrics');
      const json = await response.json();
      setData(json);
    }, 5000);

    return () => clearInterval(interval);
  }, []);

  return <div>{JSON.stringify(data)}</div>;
}

That snippet has two distinct flaws. First, if a request takes longer than the polling interval—say your server gets bogged down and a response takes six seconds while you poll every five—requests start piling up concurrently. Second, you aren't canceling inflight network requests when the component unmounts. React will throw a warning about updating state on an unmounted component if the final request resolves after the user navigates away. To make matters worse, related issues like handling form mutations cleanly are detailed in React Form State with Server Actions, but client-side polling requires a different set of primitives.

Network congestion makes this worse. When requests stack up, the browser exhausts its connection pool for that domain. Other legitimate API calls stall out waiting for the polling loop to finish its backlog. You must enforce serial execution to fix this architectural flaw. A recursive timer solves the overlap by ensuring the next request only schedules after the previous promise settles.

Using AbortController for Request Cancellation

The native JavaScript AbortController solves the cancellation problem completely. It lets you abort ongoing fetch requests when conditions change. This happens when a component unmounts or when a new polling tick fires before the previous one completes.

By passing an AbortSignal to your fetch call, you can cleanly terminate the network socket. This prevents the promise from resolving with stale data. Here is how you wire it up inside an effect using a recursive timeout pattern.

import { useState, useEffect } from 'react';

export function ResilientPoller() {
  const [metrics, setMetrics] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    let timeoutId: NodeJS.Timeout;

    const poll = async () => {
      try {
        const response = await fetch('/api/metrics', {
          signal: controller.signal,
        });
        if (!response.ok) {
          throw new Error('Network response failed');
        }
        const result = await response.json();
        setMetrics(result);
        setError(null);
      } catch (err) {
        if ((err as Error).name !== 'AbortError') {
          setError((err as Error).message);
        }
      } finally {
        if (!controller.signal.aborted) {
          timeoutId = setTimeout(poll, 5000);
        }
      }
    };

    poll();

    return () => {
      controller.abort();
      clearTimeout(timeoutId);
    };
  }, []);

  if (error) {
    return <div>Error: {error}</div>;
  }

  return <div>Metrics: {JSON.stringify(metrics)}</div>;
}

This implementation switches from setInterval to a recursive setTimeout pattern inside the async function. That guarantees serial execution. The next poll never schedules until the current request either finishes successfully or throws an error. If the component unmounts, the cleanup function triggers controller.abort() and clears any pending timeout, silencing background requests immediately.

Catching the AbortError is crucial. Without that check, aborted network requests bubble up as unhandled promise rejections or pollute your error state boundaries. The browser throws an error named AbortError whenever a fetch signal triggers an abort. You must explicitly ignore that specific error name while catching genuine network failures like drops or server errors.

Handling Dynamic Dependencies and Stale Closures

Real-world applications require polling endpoints that change based on user input. Think about switching a view between different project IDs or filter toggles. If your polling loop depends on a changing prop or state variable, you must clear the previous timer and restart the loop whenever that dependency changes.

Failing to include dependencies correctly results in stale closures where your fetch function uses old variable values from the initial render. Conversely, including dependencies without proper cleanup causes memory leaks through multiplying intervals. For managing broader component state effectively, you might want to review Advanced Component State Colocation in React to keep your architecture clean.

When writing these hooks, always verify your cleanup logic by toggling components rapidly in your test suite. If your network tab shows pending requests lingering after unmount, your abort signal isn't wired up to the active promise chain. Keep your polling logic explicit. Avoid magical abstractions that hide the underlying timers, and let native web APIs handle the heavy lifting of request cancellation.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Fixing Next.js Server Action Timeout Errors

    Fixing Next.js Server Action Timeout Errors

    Resilient Polling Loops with React and AbortController

    Resilient Polling Loops with React and AbortController

    Fixing Nginx Proxy Buffers to Prevent 502 Errors

    Fixing Nginx Proxy Buffers to Prevent 502 Errors

    Next.js Cache Tags and On-Demand Revalidation

    Next.js Cache Tags and On-Demand Revalidation

    Fixing Hydration Mismatches in React Components

    Fixing Hydration Mismatches in React Components

    Securing Next.js Route Handlers Against Abuse

    Securing Next.js Route Handlers Against Abuse

    Advanced Component State Colocation in React

    Advanced Component State Colocation in React

    Optimizing Linux Kernel Parameters for Nginx

    Optimizing Linux Kernel Parameters for Nginx

    Production Python Dependencies With UV and Lockfiles

    Production Python Dependencies With UV and Lockfiles

    Safe Tenant Isolation in Laravel Multi Database Apps

    Safe Tenant Isolation in Laravel Multi Database Apps