DEV Community

nareshipme
nareshipme

Posted on

How to Prevent Stuck Loading Spinners with Polling Timeouts in React

You've seen it: the user kicks off an async job, the loading spinner appears, and then… nothing. The spinner just spins forever. The job may have completed, crashed, or timed out on the server — but the client has no idea.

This is a common failure mode when you're polling a status endpoint. Here's how to fix it with a simple timeout guard.

The Problem

Polling usually looks something like this:

const [status, setStatus] = useState<'pending' | 'processing' | 'done' | 'error'>('pending');

useEffect(() => {
  if (status === 'done' || status === 'error') return;

  const interval = setInterval(async () => {
    const res = await fetch(`/api/projects/${id}/status`);
    const data = await res.json();
    setStatus(data.status);
  }, 2000);

  return () => clearInterval(interval);
}, [status, id]);
Enter fullscreen mode Exit fullscreen mode

The bug: if the server-side job gets stuck (Inngest step hung, ffmpeg process zombie'd, webhook never arrived), status stays 'processing' forever. The interval keeps firing. The user keeps staring at a spinner.

The Fix: Absolute Deadline

Add a start time and bail out if the deadline passes:

const POLLING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

const [status, setStatus] = useState<'pending' | 'processing' | 'done' | 'error' | 'timeout'>('pending');
const pollingStartRef = useRef<number | null>(null);

useEffect(() => {
  if (status === 'done' || status === 'error' || status === 'timeout') return;

  // Record when we started polling
  if (!pollingStartRef.current) {
    pollingStartRef.current = Date.now();
  }

  const interval = setInterval(async () => {
    // Check timeout before every poll
    if (Date.now() - pollingStartRef.current! > POLLING_TIMEOUT_MS) {
      setStatus('timeout');
      return;
    }

    try {
      const res = await fetch(`/api/projects/${id}/status`);
      if (!res.ok) throw new Error('Bad response');
      const data = await res.json();
      setStatus(data.status);
    } catch {
      // Network error — keep polling until timeout
      console.warn('Polling error, retrying...');
    }
  }, 2000);

  return () => clearInterval(interval);
}, [status, id]);
Enter fullscreen mode Exit fullscreen mode

Now render a timeout state in your UI:

if (status === 'timeout') {
  return (
    <div className="error-state">
      <p>This is taking longer than expected.</p>
      <button onClick={() => {
        pollingStartRef.current = null;
        setStatus('pending');
      }}>
        Try again
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why useRef for the Start Time?

Because useState would cause a re-render on every interval tick if we stored elapsed time there. We only need the start timestamp as a stable reference — no re-renders needed until we actually hit the deadline.

Choosing a Timeout Value

Pick based on your P99 completion time:

  • Short jobs (< 30s typical): 2–3 min timeout
  • Medium jobs (30s–2min typical): 5–7 min timeout
  • Long jobs (video processing, ML inference): 10–15 min timeout

When in doubt, err longer. A timeout that fires on legitimate jobs is more frustrating than a spinner that waits a few extra minutes.

Going Further: Server-Side Deadline Too

Client-side timeouts protect the UI, but the server-side job might still be burning resources. Add a server-side deadline as well — set a started_at timestamp when the job begins, and mark it timed_out if it exceeds your SLA:

// In your status API
const job = await db.from('projects').select().eq('id', id).single();

const SERVER_TIMEOUT_MS = 10 * 60 * 1000;
if (
  job.status === 'processing' &&
  Date.now() - new Date(job.started_at).getTime() > SERVER_TIMEOUT_MS
) {
  await db.from('projects').update({ status: 'error', error: 'Timed out' }).eq('id', id);
  return NextResponse.json({ status: 'error', error: 'Processing timed out' });
}
Enter fullscreen mode Exit fullscreen mode

This keeps your database state consistent and lets any other UI (notifications, emails) know the job actually failed.

TL;DR

  • Track pollingStartRef = Date.now() when polling starts
  • On every poll tick, check if Date.now() - start > timeout
  • Expose a 'timeout' state with a retry option
  • Match your timeout value to your P99 job duration
  • Add a server-side deadline too for consistency

Stuck spinners are a solved problem — they just require a bit of defensive coding upfront.

Top comments (0)