Stop Lying to Users: Building Progress Bars That Tell the Truth

December 2025 · Derick Zr · 7 minutes read

You know those progress bars that claim they're "Installing updates: 87%" but then sit there for another ten minutes?

I shipped one of those.

Not on purpose. But I did it. And then I had to fix it.

The Problem

I was building an image optimization service. Users upload images, I process them with sharp, generate webp variants, and package everything for download.

The processing pipeline:

  • Validate uploaded files
  • Optimize images (compression + webp conversion)
  • Generate multiple sizes (thumbnail, medium, large)
  • Package everything into a zip file
  • Generate download link

Takes 2-3 minutes on average. Longer if someone uploads dozens of high-res images.

Users are waiting. They need to see progress. Four steps:

  1. Preparing upload
  2. Optimizing images
  3. Generating variants
  4. Packaging download

Seems straightforward, right?

The First Attempt (The Lie)

Here's what I shipped first:

useEffect(() => {
  if (job?.status === "processing") {
    let currentIndex = 0;
 
    const timer = setInterval(() => {
      if (currentIndex < statuses.length) {
        setStatuses((current) =>
          current.map((item, index) => ({
            ...item,
            status: index <= currentIndex ? "completed" : item.status,
          })),
        );
        currentIndex++;
      } else {
        clearInterval(timer);
        router.push("/download");
      }
    }, 1000);
 
    return () => clearInterval(timer);
  }
}, [job?.status]);

Wait for the processing API to return status: "processing", then increment a status every second until we're done.

It worked. It looked good. I saw progress.

But it was a lie.

The statuses had nothing to do with what was actually happening. The server could still be optimizing images while I showed "Generating variants." Or worse, it could be stuck on a massive PNG while I confidently displayed "Packaging download."

The Real Solution

I was already polling the processing API every 10 seconds to check status. I just wasn't using that information.

const { data } = useQuery({
  queryKey: ["job", jobId],
  queryFn: () => getJobStatus(jobId!),
  enabled: !!jobId,
  refetchInterval: (query) => {
    if (status !== "processing") return false;
 
    const jobData = query.state.data;
    if (
      jobData?.status === "completed" ||
      jobData?.status === "failed"
    ) {
      return false;
    }
 
    if (pollCountRef.current >= 18) return false;
 
    pollCountRef.current += 1;
    return 10000;
  },
});

Every 10 seconds, increment pollCountRef. When we hit 18 polls (3 minutes), stop. If the backend returns completed or failed before then, stop early.

Now I had real data. I just needed to map it to my 4 statuses.

Fake Progress (The Lie)

React State

Timer-based progress that doesn't reflect what's actually happening on the server

⚠️ This progress bar is lying to you. It increments every second regardless of actual server status.

Preparing deployment
pending
Optimizing assets
pending
Building static pages
pending
Finalizing deployment
pending

The server could be stuck optimizing a large image while this shows "Finalizing deployment." That's the problem with fake progress.

The Math

I poll every 10 seconds. I wait up to 3 minutes. That's 18 polls total.

Four statuses. Eighteen polls. How do I distribute them?

I went with even distribution:

const getStatusFromPollCount = (
  pollCount: number,
  statusIndex: number,
): "pending" | "in-progress" | "completed" | "failed" => {
  if (pollCount >= 0 && pollCount <= 4) {
    if (statusIndex === 0) return "in-progress";
    return "pending";
  }
 
  if (pollCount >= 5 && pollCount <= 9) {
    if (statusIndex === 0) return "completed";
    if (statusIndex === 1) return "in-progress";
    return "pending";
  }
 
  if (pollCount >= 10 && pollCount <= 13) {
    if (statusIndex <= 1) return "completed";
    if (statusIndex === 2) return "in-progress";
    return "pending";
  }
 
  if (pollCount >= 14 && pollCount <= 17) {
    if (statusIndex <= 2) return "completed";
    if (statusIndex === 3) return "in-progress";
    return "pending";
  }
 
  if (pollCount >= 18) {
    if (statusIndex <= 2) return "completed";
    if (statusIndex === 3) return "failed";
    return "pending";
  }
 
  return "pending";
};

Polls 0-4: First status in progress.
Polls 5-9: Second status in progress.
Polls 10-13: Third status in progress.
Polls 14-17: Fourth status in progress.
Poll 18+: Mark the last status as failed and show a timeout warning.

The Math: Poll Count → Status Mapping

React State

See how 18 polling attempts (3 minutes) map to 4 deployment statuses

Time: 0m 0s
05101518+
Preparing deployment
in progress
Optimizing assets
pending
Building static pages
pending
Finalizing deployment
pending

Poll Count Ranges:

Polls 0-4
First status
Polls 5-9
Second status
Polls 10-13
Third status
Polls 14-17
Fourth status
Poll 18+
Timeout - mark as failed

💡 Each status change means we've made another polling attempt. Progress reflects time elapsed and attempts made, not fake timers.

Now the progress bar reflected reality. Each status change meant I'd made another polling attempt. Still not perfect—I couldn't know exactly what the server was doing—but at least the progress matched something real: time elapsed and attempts made.

The Bug That Wasn't Obvious

I deployed this to production. Tested it with real uploads. Worked perfectly.

Then I tested the timeout scenario. When processing takes too long, I show users a warning with a retry button.

After retry, I immediately saw the timeout warning again. No progression. Just straight to failure.

The console told the story:

pollCountRef 2
pollCountRef 2
pollCountRef 6
pollCountRef 6
pollCountRef 6
...

The ref jumped from 2 to 6 almost instantly. Why?

Look at the query key:

const { data } = useQuery({
  queryKey: ["job", jobId, pollCountRef.current], // ❌ This is the problem
  queryFn: () => getJobStatus(jobId!, pollCountRef.current),
  // ...
});

I put pollCountRef.current in the query key thinking it would help React Query track state.

It created a feedback loop:

  1. Component mounts with pollCountRef = 0
  2. Query runs with key ["job", "abc123", 0]
  3. refetchInterval fires, increments ref to 1
  4. Query key changes to ["job", "abc123", 1]
  5. React Query sees a new key and runs a new query
  6. That query's refetchInterval immediately fires, increments to 2
  7. New key again. New query. Increment to 3.
  8. And so on.

The ref cascaded through all 18 values in milliseconds.

The fix:

const { data } = useQuery({
  queryKey: ["job", jobId], // ✅ Just the jobId
  queryFn: () => getJobStatus(jobId!),
  // ...
});

The ref should only be used inside queryFn to pass to the API. Not in the query key.

Real Polling-Based Progress (The Truth)

React State

Progress driven by actual polling attempts, not fake timers

✓ This progress reflects reality. Each status change means we made another polling attempt.

Poll Count:0
Time:0m 0s
Status:idle
Preparing deployment
in progress
Optimizing assets
pending
Building static pages
pending
Finalizing deployment
pending

Demo speed: 1 second per poll (real deployment: 10 seconds per poll). Choose a scenario to control the outcome: Auto succeeds randomly after 8+ polls, Success completes at 12 polls, Timeout reaches 18 polls.

The Retry Problem

When users hit the timeout warning, they can click "Retry Processing."

My first instinct:

const handleRetryProcessing = () => {
  window.location.reload();
};

Full page reload. Reset everything. Start fresh.

It worked. But it's heavy-handed. Users lose scroll position, React Query cache, any other state on the page.

Better solution:

const queryClient = useQueryClient();
 
const handleRetryProcessing = () => {
  pollCountRef.current = 0;
  setPollCount(0);
  setShowTimeoutWarning(false);
  setStatuses([
    { title: "Preparing upload", status: "in-progress" },
    { title: "Optimizing images", status: "pending" },
    { title: "Generating variants", status: "pending" },
    { title: "Packaging download", status: "pending" },
  ]);
  queryClient.invalidateQueries({ queryKey: ["job", jobId] });
};

Reset the ref. Reset the state. Invalidate the query.

React Query refetches. Polling starts from zero. No reload needed.

Retry After Timeout

React State

Compare query invalidation vs full page reload for retry functionality

When deployment times out, you need a way to retry. Which approach is better?

Poll Count:0/ 18
Time:0m 0s
Preparing deployment
in progress
Optimizing assets
pending
Building static pages
pending
Finalizing deployment
pending
✓ Query Invalidation (Better)
  • • Resets state without page reload
  • • Preserves scroll position and other state
  • • Faster and smoother user experience
  • • Works with React Query cache
⚠ Full Page Reload (Heavy-handed)
  • • Loses all state including scroll position
  • • Clears React Query cache
  • • Slower, causes page flash
  • • Should be avoided when possible

Demo speed: 0.5 seconds per poll (real deployment: 10 seconds). Choose Success to see normal completion, or Timeout to test retry methods.

The Timeline Adjustment

After watching real users process images, I realized I was showing the timeout warning too early.

Most jobs take 2-3 minutes. I was only polling for 1 minute (6 polls × 10 seconds).

The math needed adjustment:

  • Old: 6 polls = 1 minute
  • New: 18 polls = 3 minutes

Update three places:

  1. Stop condition: pollCountRef.current >= 18 (was 6)
  2. Mapping function: Redistribute 18 polls across 4 statuses
  3. Timeout check: if (pollCount >= 18) (was 6)

Now users get 3 minutes before seeing the timeout warning. Enough time for processing to complete under normal conditions, even with dozens of high-resolution images.

What This Actually Fixed

Before:

  • I saw fake progress that meant nothing
  • Timeout warnings appeared too early (1 minute)
  • Retry required a full page reload
  • A subtle React Query bug caused instant timeouts after retry
  • The UI lied about what was happening

After:

  • Progress reflects actual polling attempts
  • Each status change means another 10-second check passed
  • Timeout warnings appear after 3 minutes (18 polls)
  • Retry is instant and preserves state
  • No feedback loops or cascading queries

The Takeaways

Don't fake progress unless you have to. If you're polling anyway, use that data. Map poll attempts to visual states.

Query keys matter. Putting reactive values in your query key can create cascading effects. Use them for cache invalidation, not for tracking state.

Refs are for values you don't want to trigger renders. If you need the UI to react, use state. If you just need to track something across renders, use a ref.

Retry shouldn't require reload. If you're using React Query, invalidateQueries is almost always better than window.location.reload().

Test the edge cases. The happy path worked fine. The timeout scenario revealed the bug.

The progress bar still looks the same. But now it tells the truth.

And when things take longer than expected, I can retry without losing state.

For a side project, this felt like overkill at first. But every time I see users wait through processing, I appreciate showing them real progress instead of fake reassurance.

That's worth the extra complexity.

Stop Lying to Users: Building Progress Bars That Tell the Truth