Background Jobs Need Frontend States
Every background job runs through a state machine: queued, processing, complete, failed. Your frontend needs to model all four — not just the happy path.
Background jobs have a state machine. Queued, processing, complete, failed — four states, each with its own UI contract. Most frontend implementations handle complete and maybe failed. The other two get a spinner and a prayer. That's why users refresh the page, submit the form twice, or file support tickets saying "it just stopped."
The fix isn't a better spinner. It's modeling the actual state machine the backend already has.
The State Machine You Already Have
Every job queue — Bull, BullMQ, Sidekiq, Temporal, even a custom pg table — runs the same transitions:
idle → queued → processing → complete
↓
failed → (retry) → processing
Your frontend should map to these states one-to-one. If the backend has a queued state, show the user they're in queue. If there's a position number, show that too. "Position 4 of 12" is infinitely more trustworthy than a spinning circle.
Polling vs Webhooks vs SSE
Three patterns exist for getting job status to the client. Each has a real cost.
Polling is the easiest to implement and the easiest to get wrong. The mistake is polling on a fixed interval regardless of job duration. A 500ms poll for a 30-second job wastes requests. A 5-second poll for a 200ms job feels broken.
Use exponential backoff with a ceiling: start at 1s, back off to 10s max, reset on state change. Here's a hook that does this:
function useJobStatus(jobId: string | null) {
const [status, setStatus] = useState<JobStatus | null>(null);
const intervalRef = useRef<number>(1000);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!jobId) return;
let cancelled = false;
async function poll() {
try {
const res = await fetch(`/api/jobs/${jobId}`);
const data: JobStatus = await res.json();
if (!cancelled) {
setStatus(data);
if (data.state === "complete" || data.state === "failed") return;
// Back off: 1s → 2s → 4s → 8s → 10s (ceiling)
intervalRef.current = Math.min(intervalRef.current * 2, 10_000);
timerRef.current = setTimeout(poll, intervalRef.current);
}
} catch {
if (!cancelled) {
timerRef.current = setTimeout(poll, intervalRef.current);
}
}
}
poll();
return () => {
cancelled = true;
clearTimeout(timerRef.current);
};
}, [jobId]);
return status;
}
SSE (Server-Sent Events) is the right call when job duration is unpredictable and you control the server. One persistent connection, server pushes updates. No wasted requests. The tradeoff: you need to handle reconnection, and load balancers that don't support persistent connections will cut you off.
Webhooks work when you're dealing with third-party async operations (Stripe, Twilio, Render deployments). The job is off your server entirely. You receive a POST when something changes. The catch: you can't push webhook data directly to a browser tab — you still need a polling layer or a WebSocket between your backend and the client.
The Empty State While Polling
The moment between "user submits form" and "first poll response arrives" is the most forgotten state. The job ID might not exist yet. The first fetch might return 404. Handle it explicitly:
- Show "Starting job…" before you have a
jobId - Show "Checking status…" while the first poll is in flight
- Never show a blank screen or a recycled UI from a previous state
Showing Partial Progress
Some jobs have sub-steps. Video encoding has stages. A data import has a row count. If your backend can emit progress — percentage, current step, rows processed — expose it.
The UI doesn't need to be a progress bar. A text line like "Processing row 1,240 of 4,000" is enough. It proves the job is alive and gives the user a rough ETA without committing to one. Committing to ETAs you can't guarantee is how you create trust problems.
Store progress in the same job status endpoint. Return a progress field when the state is processing:
{
"state": "processing",
"progress": { "current": 1240, "total": 4000, "step": "importing rows" }
}
Error Recovery
failed is not a terminal display state. It's an opportunity. Show what failed, and give the user an action. "Retry" is a valid action. So is "Contact support" with a pre-filled subject line that includes the jobId.
Log the jobId server-side against the error. When a user pastes that ID into a support ticket, your team should be able to pull the full job trace in under a minute. If they can't, that's a logging problem, not a UX problem.
What This Actually Looks Like
A job status component should render one of five things: empty (no job yet), loading (first poll in flight), queued (job exists, waiting), processing (with progress if available), complete, or failed with recovery options. Six states total. If your component has fewer than four, you're hiding real states behind a spinner.
The backend already has this state machine. Your job is to reflect it honestly, not paper over it.