Skip to story

Psychology Perception

Fake Progress Bars and Trust

7 min read · May 31, 2026 ★ Flagship

Reading level

The bar that lies at 95%

You've seen it: a progress bar races to 90%, then slows to a crawl. It hits 95% and stalls. You wait five seconds. Ten. Fifteen. The bar doesn't move. You start wondering if something broke. Finally, at 100%, the page loads. That wait felt much longer than if the bar had never made a promise at all.

The bar was fake. It was a CSS animation with a timer, not connected to actual progress. The 95% stall happened because the real work took longer than the fake bar predicted. You felt betrayed by a number.

The psychology: a percentage bar sets a precise numerical expectation. The brain treats "95% done" as "5% remaining." When the remaining 5% takes 40% of the total time, the expectation is violated — and expectation violation feels significantly worse than a wait with no expectation set. An honest indeterminate indicator (a spinner, a pulse animation) sets no expectation and therefore cannot be violated.

The research (Antonides et al., 2002 on wait experience; applied to UI loading by multiple UX teams) distinguishes between "wait experience" and "wait expectation violation." A spinner is a pure wait experience — uncomfortable but not betraying. A fake percentage bar adds expectation violation on top of the wait. Measurement: perceived wait time in violation conditions can run 40–60% longer than clock time, vs 10–20% longer for honest spinners.

The loading indicator decision tree

Under 200ms: show nothing. A flash of a spinner for a fast operation is more distracting than useful.

200ms – 3s: show an indeterminate indicator — spinner, animated dots, or a looping progress pulse. No percentage, no ETA.

Over 3s with known progress: show real deterministic progress. Use actual milestones: "Uploading (1 of 3 files)", "Processing…", "Almost done." Real milestones are more trustworthy than fake percentages.

Over 3s without known progress: stay indeterminate but add text ("This may take a minute"). Honesty about unknowns beats fabricated precision.

// ❌ Fake progress: setTimeout driving a CSS animation
const [progress, setProgress] = useState(0);
useEffect(() => {
  const timer = setInterval(() => {
    setProgress(p => Math.min(p + 2, 95)); // stalls at 95%
  }, 100);
  return () => clearInterval(timer);
}, []);

// ✅ Real progress: event-driven from actual upload
xhr.upload.addEventListener('progress', (e) => {
  if (e.lengthComputable) {
    setProgress(Math.round((e.loaded / e.total) * 100));
  }
});

If you don't have real progress events, use an indeterminate animation — not a fake deterministic one.

When you genuinely have async work with no progress signal (e.g., a server-side job with no streaming), the honest options are:

  • Indeterminate animation — simple and honest
  • Polling with last-known status — "Processing your video… (started 12s ago)"
  • Step-based labeling — show named stages ("Transcoding", "Generating thumbnails") even without percentages; each stage name is a real server event, not a timer
  • Optimistic background processing — let the user do other things, notify on completion

The principle: every number you display is a promise. Only display numbers you can keep.

The checkout that lied about 90%

An e-commerce team noticed their checkout completion rate had dropped. Users were starting payment, watching a progress bar fill to 90%… and then leaving. Session recordings showed the same pattern: the bar would rush to 90% and freeze there for ten, twenty, thirty seconds. Users assumed the payment had failed.

The bar was fake. A timer incremented it to 95% regardless of what the payment processor was doing. The real processing took anywhere from 2 to 25 seconds. At 90%, the timer stopped — and so did the user's trust.

The team traced the abandonment to a setInterval driving the bar at 2% per 100ms — a classic fake progress implementation. When the bar stalled at 95%, the cognitive dissonance was severe: users had been told "almost done" for 20 seconds. An expectation had been set and violated.

Switching to an indeterminate indicator — a looping pulse with the copy "Processing payment…" — reduced abandonment by 38% without changing processing time. The fix wasn't faster checkout; it was honest checkout.

Baymard Institute's checkout UX research identifies fake progress as one of the highest-friction patterns in payment flows: users mentally account for "almost done" as a promise. Stalling at 90%+ triggers the same anxiety response as an error message — often worse, because the expected action (confirmation) never arrives.

The correct model: for operations with no real progress signal (payment gateway call, background job), use an indeterminate indicator. For operations with real progress events (file upload via XHR.upload.progress, multi-step server pipeline via SSE), bind to real events. The 95% fake stall is strictly worse than an honest spinner with elapsed time.

Progress indicator comparison

Annotated example: payment processing screen

❌ FAKE DETERMINISTIC

Processing payment…

90% — frozen here for 20s

User expects "10% left = seconds". Violation → abandonment.

✅ HONEST INDETERMINATE

Processing payment…

Duration unknown — no promise made

User knows: working. No false expectation to violate.

Watch it: fake vs honest progress

The "Fake" animation shows a progress bar rushing to 90%, then stalling. Notice the moment of psychological discomfort at the stall. The "Honest" animation shows an indeterminate pulse — no number, no false promise, no betrayal.

Both animations represent the same 4-second operation. The fake bar makes the wait feel longer because of the stall. The honest indicator makes the wait feel shorter because there is no violated expectation.

The third option in the demo shows real deterministic progress with named milestones from actual server-sent events. Compare the perceived wait across all three for the same clock duration.

⚡ Interactive demo

Implementing honest progress

For file uploads, real progress events are built into the browser. Bind to XMLHttpRequest.upload.onprogress — it fires with actual bytes sent vs total bytes. The pattern: bind to a real event, not a timer.

const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
  if (e.lengthComputable) {
    const pct = Math.round((e.loaded / e.total) * 100);
    progressBar.style.width = pct + '%';
    progressLabel.textContent = pct + '%';
  }
};
xhr.open('POST', '/upload');
xhr.send(formData);

The key pitfall: e.lengthComputable is false when the server doesn't send a Content-Length header (common with chunked transfer encoding). Always check it before dividing — if it's false, fall back to an indeterminate animation.

Two patterns for response-side progress (when you care about the server processing the upload, not just the upload itself):

// fetch + ReadableStream: response body progress
const res = await fetch('/process', { method: 'POST', body: file });
const reader = res.body.getReader();
let received = 0;
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  received += value.length;
  // Update progress if total is known
}

// Server-Sent Events: multi-step server job progress
const es = new EventSource('/job/' + jobId + '/progress');
es.onmessage = (e) => {
  const { stage, percent } = JSON.parse(e.data);
  updateProgress(stage, percent); // named stages, not raw numbers
};
es.addEventListener('done', () => {
  es.close();
  showComplete();
});

The SSE pattern is correct for multi-step server jobs (video transcoding, report generation, batch imports) where upload is instant but processing takes 10–60 seconds. Each stage emits a server event with its name and percentage — the client maps stage names to user-facing copy rather than displaying raw percentages.

The production architecture for multi-step server progress via SSE: each processing stage on the server emits data: {"stage": "transcoding", "percent": 45} over the event stream. The client maintains a stage map — { transcoding: "Preparing your video", thumbnails: "Generating previews", indexing: "Almost done" } — and displays stage names as milestones rather than raw percentages. This is trustworthy progress: users see "Preparing your video" for a real processing stage, not "47%" that could stall at any moment. Baymard Institute's checkout UX research identifies named milestone progress as significantly less anxiety-producing than percentage bars for operations where the total duration is variable. The design principle: name the stages, not the percentage — stages are honest because they correspond to real server events; percentages are only honest when they correspond to real byte ratios. Map stages to a percentage range (e.g., transcoding = 0–60%, thumbnails = 60–85%, indexing = 85–99%) for the visual bar, but surface stage names as the primary copy. Never let the bar reach 100% before the operation actually completes.

The upload bar that froze at 90%

A team building a file-upload dashboard wanted to show users that something was happening. They added a progress bar animation — a CSS keyframe that ran from 0% to 90% over three seconds, regardless of how fast the actual upload was moving. It felt snappy in demos on the office Wi-Fi. In production, on a slow mobile connection, it fell apart.

On a slow connection the real upload hadn't finished when the CSS animation hit 90%. The bar froze. Users stared at "90% done" for 45 seconds. Then the server responded, and the bar jumped instantly to 100%. Support tickets started arriving within a week: "Is it stuck?" "Did my file upload?" "Should I try again?"

The implementation was a single CSS rule: animation: progress-bar 3s ease-out forwards with a keyframe that stopped at width: 90%. The 90% ceiling was intentional — the team left 10% headroom for the server to "catch up." What they didn't account for was that on a 1 Mbps connection, a 10 MB file takes about 80 seconds, not 3. The animation finished more than a minute before the upload did.

The freeze wasn't a bug in the usual sense — the code did exactly what it was written to do. The bug was the assumption: that a CSS timer could stand in for real upload state. It couldn't, and the gap between the timer's confidence and reality's pace is exactly where user trust erodes.

This is a textbook expectation-violation failure. The animation set a precise numerical promise (90% in 3 seconds), then held that promise frozen while reality diverged. The longer the stall, the larger the violation. A 45-second freeze at "90%" is worse than showing nothing — the user has been told they are almost done for the majority of the actual wait time, so any remaining wait is experienced as broken. The correct fix is not to tune the animation timing; it is to remove the decoupling between the visual and the actual state.

When real upload progress still isn't enough

After the support tickets, the team switched to real upload progress events. They wired up XMLHttpRequest.upload.onprogress and drove the bar from actual bytes sent versus total bytes. It worked — the bar moved at the real upload speed, and the freeze at 90% was gone. Then they noticed a new problem.

Their pipeline had a second step: after the file landed on the server, a resizing job ran on it — typically 8 to 15 seconds. The upload progress bar hit 100% the moment the last byte arrived. But the resizing hadn't finished. A spinner appeared below the completed bar to signal the processing step. Users now saw two things at once: a progress bar at 100% and a spinner still running. The two states contradicted each other.

XMLHttpRequest.upload.onprogress only fires for the upload phase — bytes leaving the browser. It has no visibility into what the server does after receiving the file. The bar reaching 100% told the user "done," but the server wasn't done. The spinner was added as a patch, but it competes with the completed bar rather than replacing it. Two simultaneous loading states create ambiguity: which one should the user watch? Which one signals completion?

The underlying issue is that the team modeled progress as a single dimension (upload bytes) when the actual operation had two dimensions (upload + server processing). A single bar cannot honestly represent both without a redesign of what the bar is tracking.

The correct architecture segments the operation into named stages that each correspond to a real server event. Upload completion fires one event; server-side processing completion fires another — via Server-Sent Events, WebSocket, or polling. The UI maps these events to distinct visual states: "Uploading," "Processing," "Complete." No stage claims completion before its corresponding server event arrives. The competing-states failure (bar at 100%, spinner still running) is a symptom of a single indicator trying to represent multi-phase work it has no signal for past phase one.

Before — fake animation decoupled from reality

<!-- CSS-animated bar: always finishes in 3s regardless of actual upload -->
<div class="progress-bar">
  <div class="progress-bar__fill
              progress-bar__fill--animating"></div>
</div>
/* CSS */
.progress-bar__fill--animating {
  animation: fake-progress 3s ease-out forwards;
}
@keyframes fake-progress {
  from { width: 0%; }
  to   { width: 90%; }  /* freezes here until server responds */
}
Bar freezes at 90% — user has no idea if the upload is working.

After — segmented stages reflect actual server state

<!-- Segmented progress: each stage maps to a real event -->
<div class="progress-stages" aria-label="Upload progress">
  <div class="stage" data-stage="upload"   aria-label="Uploading"></div>
  <div class="stage" data-stage="process"  aria-label="Processing"></div>
  <div class="stage" data-stage="complete" aria-label="Complete"></div>
</div>
// JS: advance stage on real events
xhr.upload.onprogress = () => activate('upload');
xhr.onload = () => activate('process');
serverPoll.onComplete = () => activate('complete');
Each dot maps to a real event — no frozen state, no competing spinner.

References

Remember

Key takeaways

  • A spinning circle or a pulsing bar with no percentage is honest — it says "working, don't know how long." A fake 95% stall says "I lied to you." Choose honesty.
    The 200ms / 3s thresholds: nothing under 200ms, indeterminate from 200ms–3s, real milestones over 3s. Only show deterministic progress when you have real progress events.
    Expectation violation compounds wait pain. Every percentage you display is a numerical promise. Fabricating precise numbers for inherently indeterminate operations reliably increases perceived wait time.
  • If you're using setTimeout to drive a progress bar, you're faking it. Use real upload progress events, server-sent events, or WebSocket progress for real deterministic bars.
    For genuinely unknowable async work: indeterminate animation + elapsed time label ("Processing… 12s") or step-based labeling from real server events — never timer-fabricated percentages.
    The design principle: every number is a promise. Named stages from real server events are trustworthy. A spinner with honest copy ("This may take a minute") outperforms a fake percentage bar in both trust and measured perceived wait time.

Enjoyed this case?

Case 1 of 4 in Psychology Perception · 20 of 31 live

Keep going

Finish this takeaway, then continue the track — Casey saved your spot locally.

Sign in with email to sync progress across devices (beta).

Inside the Casebook

New cases every few weeks — patterns from production UI engineering. Double opt-in, easy unsubscribe.

No spam. Unsubscribe anytime. Emails sent via Buttondown.

RSS feed
Casey, junior (idle)
Casey · Junior

Hey! I'm Casey — scroll through the case and I'll chime in with hints.