DEV Community

Cover image for Polling Long-Running AI Jobs on Mobile Web: Retries, Backoff, and the visibilitychange Trick
Belle AI
Belle AI

Posted on

Polling Long-Running AI Jobs on Mobile Web: Retries, Backoff, and the visibilitychange Trick

Most AI features I've shipped have the same shape: the user uploads something, the server kicks off a job that takes anywhere from a few seconds to a minute, and the client has to wait.

In Belle AI (a selfie-based color analysis tool), a report takes about 10-60 seconds. Most users are on phones, so the "just poll every N seconds" approach fell apart quickly.

This post covers the four things I had to fix. It's a follow-up to my earlier post on the Next.js SEO lessons from this project.

Disclosure: I'm the developer of Belle AI.

The shape of the API

Two calls: one to create a task, and one to poll it.

const { data } = await createTask(imageUrl, "color_analysis", "en");
// data.task_id

const res = await pollTask(data.task_id);
// res.data.status: "pending" | "done" | "failed"
Enter fullscreen mode Exit fullscreen mode

Simple enough. The trouble starts with the real world.

Problem 1: Uploads fail randomly on mobile

Before the job even starts, the selfie has to be uploaded. On mobile networks, timeouts and dropped connections are routine.

I added a per-attempt timeout and up to three attempts with linear backoff. The important detail is what you retry:

const UPLOAD_MAX_ATTEMPTS = 3;
const UPLOAD_TIMEOUT_MS = 45_000;

export async function uploadImage(base64: string) {
  let lastError: unknown = null;

  for (let attempt = 1; attempt <= UPLOAD_MAX_ATTEMPTS; attempt++) {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
    try {
      const res = await fetch("/upload", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ imageBase64: base64, fileType: "jpg" }),
        signal: controller.signal,
      });

      // Retry only on 5xx. 4xx is the caller's fault; retrying won't help.
      if (res.status >= 500 && attempt < UPLOAD_MAX_ATTEMPTS) {
        lastError = new Error(`HTTP ${res.status}`);
      } else {
        return await res.json();
      }
    } catch (err) {
      lastError = err; // network error or abort
      if (attempt === UPLOAD_MAX_ATTEMPTS) throw err;
    } finally {
      clearTimeout(timer);
    }
    await new Promise((r) => setTimeout(r, attempt * 1000)); // 1s, 2s
  }
  throw lastError instanceof Error ? lastError : new Error("upload_failed");
}
Enter fullscreen mode Exit fullscreen mode

Retry network errors and 5xx. Return business errors (4xx, or an error code in the body) straight to the caller.

Problem 2: Waiting a full interval before the first poll

If you poll every 10 seconds, the user stares at a spinner for 10 seconds even when the job finished in 4.

Use a shorter first delay:

const FIRST_POLL_DELAY_MS = 3000;

await waitForNextPoll(first ? Math.min(FIRST_POLL_DELAY_MS, intervalMs) : intervalMs);
Enter fullscreen mode Exit fullscreen mode

Problem 3: Backgrounded tabs freeze your timers

This was the sneaky one. A user taps "Analyze", switches to another app while waiting, then comes back. Mobile browsers throttle or freeze timers in background tabs, so the job is long done but the progress bar is still crawling.

The fix is to wait for either the timer or the tab becoming visible again:

function waitForNextPoll(ms: number): Promise<void> {
  return new Promise((resolve) => {
    let finished = false;
    const finish = () => {
      if (finished) return;
      finished = true;
      clearTimeout(timer);
      document.removeEventListener("visibilitychange", onVisible);
      resolve();
    };
    const onVisible = () => {
      if (document.visibilityState === "visible") finish();
    };
    const timer = setTimeout(finish, ms);
    document.addEventListener("visibilitychange", onVisible);
  });
}
Enter fullscreen mode Exit fullscreen mode

When the user returns, we poll immediately instead of waiting for a frozen timer.

Problem 4: "Max attempts" lies when timers are throttled

My original timeout was maxAttempts * intervalMs, counted in loop iterations. With frozen timers, iterations stop happening, so the budget stretches unpredictably.

Use wall-clock time instead:

const deadline = Date.now() + maxAttempts * intervalMs;

for (let first = true; ; first = false) {
  if (!first && Date.now() > deadline) break;
  await waitForNextPoll(first ? 3000 : intervalMs);
  // ...poll
}
throw new Error("Timed out");
Enter fullscreen mode Exit fullscreen mode

Time spent in the background now counts against the budget, which is what the user expects.

Bonus: Tolerate a few poll errors, not unlimited ones

A single failed poll shouldn't kill a job that's still running. But five in a row probably means something is wrong:

let consecutiveErrors = 0;
const maxConsecutiveErrors = 5;

try {
  const res = await pollTask(taskId);
  if (res.code === 500) {
    if (++consecutiveErrors >= maxConsecutiveErrors) throw new Error("Server error");
    continue;
  }
  consecutiveErrors = 0; // any success resets the counter
  // ...
} catch (err) {
  if (err instanceof TypeError) { // fetch network failure
    if (++consecutiveErrors >= maxConsecutiveErrors) throw new Error("Network error");
    continue;
  }
  throw err;
}
Enter fullscreen mode Exit fullscreen mode

Summary

Problem Fix
Flaky uploads Timeout + 3 attempts, retry only network errors and 5xx
Slow first feedback Shorter first poll delay
Frozen background timers Resolve the wait on visibilitychange
Unreliable max attempts Wall-clock deadline
Transient poll errors Tolerate N consecutive failures, reset on success

If you'd rather not poll at all, SSE or WebSockets are options, but polling is simple, works behind every proxy, and with these fixes it behaves well on phones.

Want to see it in action? Try the web version or the iOS app.

How do you handle long-running jobs on the client: polling, SSE, or WebSockets? I'd love to hear what's worked for you.

Top comments (0)