How to Test File Upload Progress Bars (Network Throttling Guide)
A progress bar that has only ever been tested on localhost has not really been tested. Locally, a 20 MB upload is done before the bar has painted, so bugs like a bar that jumps from 0 to 100, a cancel button that does nothing, or a missing timeout message stay hidden until users on slow connections find them. The fix is simple: slow the upload down on purpose, with a file large enough to watch.
Work out how long the upload should take
Upload time is roughly file size in bits divided by upstream bandwidth. It is worth doing the sums before you start, so you know whether the throttle is actually working.
| File | Bytes | at 1 Mbit/s up | at 5 Mbit/s up |
|---|---|---|---|
| 5 MiB | 5,242,880 | ~42 s | ~8 s |
| 24 MB | 24,000,000 | ~3 min 12 s | ~38 s |
| 100 MiB | 104,857,600 | ~14 min | ~2 min 48 s |
Real transfers add protocol overhead and latency, so expect slightly longer. The file size calculator is handy for other sizes. For most progress-bar work, a file that takes 30 to 90 seconds at your chosen throttle is ideal: long enough to interact with, short enough to repeat.
Throttle in Chrome DevTools
- Open DevTools and go to the Network tab.
- Open the throttling dropdown (it says "No throttling" by default).
- Pick a preset, or better, choose Add and create a custom profile with an explicit upload speed, for example 1,000 kbit/s up, 5,000 kbit/s down, 150 ms latency.
Preset names have changed between Chrome versions (older versions had "Fast 3G" and "Slow 3G"; newer ones use names like "Slow 4G" and "3G"), which is one reason we prefer a named custom profile: the numbers are in the test notes and do not change under you.
Then check the throttle is really applied to the upload: time a 5 MiB upload and compare it with the table. If it finishes in a second, the throttle is not affecting that request, and you need a lower-level method (below).
Throttle in automated tests
In Playwright with Chromium, you can apply the same throttling through the Chrome DevTools Protocol. Throughput values are in bytes per second:
test('upload progress and cancel', async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
await cdp.send('Network.enable');
await cdp.send('Network.emulateNetworkConditions', {
offline: false,
latency: 150, // ms
downloadThroughput: (5000 * 1000) / 8, // 5 Mbit/s
uploadThroughput: (1000 * 1000) / 8, // 1 Mbit/s
});
await page.goto('/upload');
await page.locator('input[type=file]').setInputFiles('fixtures/dummy-24mb.pdf');
const bar = page.getByRole('progressbar');
await expect(bar).toHaveAttribute('aria-valuenow', /^[1-9]/, { timeout: 20000 });
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(bar).toBeHidden();
});
This only works in Chromium. For Firefox and WebKit, or when you want to be sure the whole network path is slow, throttle outside the browser: on Linux, tc with netem can limit an interface (sudo tc qdisc add dev eth0 root netem rate 1mbit delay 150ms, and sudo tc qdisc del dev eth0 root to undo it); on macOS, Apple's Network Link Conditioner does the same from a preference pane.
How the progress value is produced
Knowing where the number comes from helps explain what you see. fetch() does not report upload progress, so most upload components still use XMLHttpRequest:
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setProgress(e.loaded / e.total);
};
xhr.upload.onload = () => setPhase('processing'); // bytes sent, server still working
xhr.onload = () => setPhase(xhr.status < 300 ? 'done' : 'error');
xhr.onerror = () => setPhase('network-error');
xhr.ontimeout = () => setPhase('timeout');
xhr.timeout = 10 * 60 * 1000;
xhr.open('POST', '/api/upload');
xhr.send(formData);
// Cancel button
cancelButton.onclick = () => xhr.abort();
Note the two separate events. xhr.upload.onload fires when the browser has finished sending; xhr.onload fires when the server has responded. If the server scans or converts the file before responding, there can be a long gap between them. A bar that sits at 100% for 20 seconds looks frozen; showing a "Processing..." state for that gap fixes it.
What to check while the upload runs
- The bar moves in steps that make sense. With a 24 MB file at 1 Mbit/s, you should see steady movement for about three minutes. If it jumps from 0 to 100, the component is probably not listening to
upload.onprogress, or the file is being read fully before sending. - 100% is followed by a processing state, not silence.
- Cancel really cancels. Open the Network tab: the request should show as cancelled, and after refreshing, no partial file should exist on the server.
- Leaving the page. Navigating away mid-upload should warn the user (
beforeunload) or clearly continue in the background, depending on your design. - Going offline. Switch the throttle to "Offline" halfway through. The UI should show a network error and offer a retry, not stay at 47% forever.
- Timeouts on the server side. Load balancers and proxies have their own idle and request timeouts. A slow upload that fails at a suspiciously round number of seconds (30, 60, 120) is usually hitting one of them.
- Several files at once. Are they sent in parallel or queued? Does the overall bar add up correctly?
- Accessibility. The bar should have
role="progressbar"witharia-valuenow, and completion or failure should be announced, not only shown by colour.
Choosing the test file
Use a file of the type your users upload and a size that fits your plan above. A PDF is a good default because the padded PDFs from the generator stay valid, so the server will process them normally after upload. For a ready-made walkthrough with a longer upload, see the 24 MB long-running upload scenario.
Keep a note of the exact file size, the throttle profile, and the measured time with each test run. When someone later changes the upload component, you can repeat the same run and compare, instead of arguing about whether it "feels slower".
