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]);
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]);
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>
);
}
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' });
}
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)