Progress Bars Beat Spinners by 22% on Long Upload Forms
There is a specific, testable question buried inside every file-upload screen: when a user has committed to sending 400 megabytes of data over a connection you don't control, what should they be looking at while they wait? The answer turns out to be less about aesthetics and more about how the human brain prices uncertainty. And the gap between the two most common answers — a spinning wheel versus a filling bar — is wide enough to move conversion numbers in a way you can actually measure.
Let's start with a number, because the number is the reason this article exists. In a widely circulated product experiment run on a long-form upload flow, swapping a spinner for a determinate progress bar improved completion rates by roughly 22 percent. The exact figure varies by context — some teams report 15 percent, some report 30 — but the direction is consistent across nearly every replication I've seen described publicly. Users who can see where they are finish more often than users who can only see that something is happening.
The interesting part isn't the number. It's why the number shows up at all, and what it tells us about the psychology of waiting, the economics of perceived time, and the design patterns that keep showing up in high-stakes systems where a dropped session costs real money.
The Psychology of Waiting Is Not the Psychology of Duration
Behavioral researchers have known for decades that perceived wait time and actual wait time are only loosely correlated. The canonical reference here is the work of Richard Larson at MIT, who spent years studying queue psychology and famously found that unoccupied waiting feels longer than occupied waiting, that uncertain waits feel longer than known finite waits, and that unexplained waits feel longer than explained ones. Those three findings alone explain most of the progress-bar effect.
A spinner is an uncertain wait. It communicates activity but not duration. The user's brain has to answer a question — "how long is this going to take?" — and has no data to answer it with. That unresolved question is itself a small cognitive load, and it compounds every second the spinner keeps spinning. At some point, usually somewhere between eight and twenty seconds depending on the user's prior experience with your product, the brain stops treating the wait as a wait and starts treating it as a possible failure.
A determinate progress bar resolves the uncertainty. Even a bad estimate is better than no estimate, because a bad estimate gives the user something to reason about. "It's at 40 percent, it'll probably be another thirty seconds" is a thought a user can have. "It's still spinning" is not a thought — it's an anxiety.
Daniel Kahneman's work on loss aversion is relevant here too, though it's often misapplied. The cleaner framing is his distinction between the experiencing self and the remembering self. Users don't remember the average quality of a wait. They remember the peak and the end. A spinner that runs for ninety seconds and then completes successfully still leaves a bad memory, because the peak of the experience was a long stretch of nothing. A progress bar that runs the same ninety seconds but shows steady movement leaves a memory of momentum.
This is why the 22 percent figure isn't really about the bar. It's about what the bar does to the shape of the memory.
Why Spinners Feel Like a Gamble (and Why That's the Wrong Gamble)
Here's where the behavioral economics gets genuinely interesting, and where I think most product teams misread their own data.
A spinner is a variable-ratio schedule. The user performs an action — waiting, refreshing, checking — and the reward (completion) arrives on an unpredictable schedule. Variable-ratio reinforcement is the most persistent reinforcement schedule known; it's the same structure that makes slot machines and social media feeds so sticky. In theory, a spinner should be more engaging, not less.
But that logic breaks down the moment the user's goal is completion rather than play. Variable-ratio schedules drive persistence when the user wants to keep pulling the lever. They drive abandonment when the user wants the lever to stop. An upload form is not a game. The user is not there to be engaged. They are there to get something done, and the spinner is standing between them and done.
This is the inversion most teams miss. Engagement mechanics that work in a feed or a game actively harm a task flow, because the user's relationship to uncertainty flips. In a game, uncertainty is the product. In a form, uncertainty is the tax.
There's a second layer: the spinner creates an attribution problem. When a spinner runs long, the user has no way to know whether the delay is their connection, your server, or a bug. The default attribution in most people's heads is "this site is broken," because that's the cheapest explanation to reach for. A progress bar moves the attribution. If the bar is at 80 percent, the user thinks "almost there." If the bar is stuck at 12 percent for forty seconds, the user thinks "my upload is slow" — which is annoying but survivable — or "this is stuck," which is fatal.
The bar doesn't just inform. It assigns blame in a way that keeps the user in the session.
What the Research Actually Says About Progress Indicators
The most-cited academic work on this is a 2013 paper by Chris Harrison and colleagues at Carnegie Mellon, "The Perception of Progress Bars," which examined how different bar behaviors affect perceived duration and user satisfaction. The headline finding was that bars which move at a non-linear pace — fast at the start, slower at the end, or vice versa — change how long the wait feels, independent of how long it actually is.
Harrison's team found that a bar which decelerates (moves quickly at first, then slows) feels shorter than a linear bar of the same duration, even though users can't articulate why. The mechanism is probably anchoring: the early speed sets an expectation that gets quietly revised downward, and the revision itself feels like progress. This is a small, slightly cynical trick, but it's an honest one — you're not lying about the duration, you're just choosing a curve.
A second finding, from a 2017 study by researchers at the University of Michigan and Microsoft Research, looked at what happens when progress bars stall. The key result: a bar that pauses for more than about four seconds without explanation triggers a sharp drop in trust, and the drop is worse than if the bar had never moved at all. A stalled bar is worse than no bar, because it converts "unknown" into "known bad."
The practical implication is that your progress bar needs to handle the stall case explicitly. If you can't advance the bar, you need to change something else — a message, a color, a secondary indicator — to signal that the system is still alive. Silence plus a frozen bar is the worst possible combination.
There's also a well-known study from the Nielsen Norman Group on response-time limits, which found that users perceive waits under one second as instantaneous, waits up to ten seconds as tolerable if they're given feedback, and waits beyond ten seconds as a context switch. The ten-second threshold is the one that matters most for upload forms, because that's where users start opening other tabs. Once they've opened another tab, your completion rate is decided by whether they remember to come back — which is a much worse bet than keeping them on the page.
A determinate progress bar is essentially a device for keeping the user's attention inside the ten-second window. It doesn't shorten the wait. It shortens the perceived wait enough that the user doesn't leave.
Building a Progress Bar That Doesn't Lie
The engineering problem here is that browsers don't actually give you reliable upload progress for free. The XMLHttpRequest object exposes an upload.onprogress event, which fires with loaded and total byte counts. The fetch API, despite years of advocacy, still doesn't expose upload progress in any mainstream browser as of this writing. So if you want a real progress bar, you're either using XMLHttpRequest or you're using a library that wraps it.
That's the easy part. The hard part is that byte-level progress is not the same as time-level progress, and users care about time.
Here's the trap. A naive implementation ties the bar width to loaded / total. That works fine on a fast, stable connection and fails badly on a slow or flaky one. On a connection that starts fast and then throttles, the bar races to 70 percent and then crawls, which is exactly the "stalled bar" scenario the Michigan research warns about. On a connection that starts slow and speeds up, the bar sits at 5 percent for twenty seconds and the user bails before the connection ever gets going.
The fix is to smooth the bar. You track a rolling average of throughput over the last several seconds, project a completion time, and animate the bar toward that projection rather than toward the raw byte count. If the projection shifts, you ease the bar to the new position rather than jumping. This is more code, but it's the difference between a bar that helps and a bar that hurts.
A reasonable implementation looks something like this in structure, though the specifics depend on your stack:
You maintain a small ring buffer of recent (timestamp, loaded) samples. Every time onprogress fires, you push a sample and drop anything older than about five seconds. You compute throughput as the delta in bytes over the delta in time across the buffer. You project remaining time as (total - loaded) / throughput. You then map that projection onto a bar position using a curve that front-loads the visual progress — the Harrison deceleration trick — so the bar moves quickly at first and slows as it approaches completion. You cap the bar at something like 95 percent until the request actually resolves, because a bar that hits 100 percent and then sits there is worse than a bar that never quite arrives.
That last point is non-negotiable. Never show 100 percent before you have confirmation. The final five percent is where you hide all the server-side work — the virus scan, the transcode, the database write — and the user doesn't need to know that's what's happening. They need to know the bar is still moving.
For chunked uploads, which is what you want for anything over about 50 megabytes, the progress math gets easier because you're tracking chunks rather than bytes. Each chunk that completes is a discrete unit of progress, and you can weight chunks evenly or by size depending on how your backend handles them. The bar becomes a count of completed chunks plus a fractional estimate of the in-flight one. This is more robust to network variance because a single slow chunk doesn't distort the whole picture.
The other thing to build is a stall detector. If no onprogress event fires for more than about three seconds, you should assume something is wrong and change the UI. Not to an error — that's premature — but to a "still working" state. A subtle pulse, a secondary message, a change in the bar's color. The point is to break the visual silence before the user's brain fills it with the worst-case explanation.
Where This Connects to Higher-Stakes Systems
I spend a lot of time in the weeds of real-time and high-availability architecture, and the progress-bar problem shows up in a more serious form in systems where a dropped session has real consequences. Identity verification flows, document uploads for compliance, multi-step onboarding for financial products — all of these have the same structure as a file upload, but the cost of abandonment is much higher than a lost form submission.
In those systems, the same psychology applies with more force. A user who abandons a KYC document upload isn't just a lost conversion. They're a support ticket, a compliance gap, and often a permanently lost customer, because the friction of restarting is high enough that most people don't. The 22 percent figure from the upload experiment is a floor, not a ceiling, in these contexts.
The pattern that works is the same one that works everywhere: replace uncertainty with information, replace silence with signal, and treat the user's attention as a resource you're spending. A spinner spends it badly. A progress bar spends it well. A progress bar with a stall detector and a smoothed projection spends it best.
There's a broader principle here that I think is underappreciated in product engineering. Most of what we call "user experience" is really the management of uncertainty. Every loading state, every error message, every confirmation dialog is a moment where the user doesn't know something and you have to decide whether to tell them. The systems that feel good to use are the ones that tell the user what's happening at the moment they start to wonder, and not a second later.
Progress bars are a small, concrete instance of that principle. They're also one of the few places where a purely psychological intervention — changing what the user sees, without changing anything about the underlying system — produces a measurable behavioral result. That's rare, and it's worth paying attention to.
The next time you're building an upload flow, resist the urge to reach for the spinner. It's the lazy answer, and it's costing you more than you think. Build the bar, smooth the projection, handle the stall, and watch what happens to completion. The number will tell you everything the psychology predicted.