Rendering Smooth Progress Bars Without Jank
Treat progress events as data, not as render triggers: write each event into a plain object keyed by file, mark the file dirty, and schedule a single requestAnimationFrame callback that updates only the dirty rows. Draw bars with transform: scaleX() on a fixed-width element (or the native <progress> value) so updates stay on the compositor instead of triggering layout; round text to whole percents and skip DOM writes when the text has not changed; ease the displayed value towards the true one for smooth motion between sparse updates; and for lists of hundreds of files, render only the rows in view. The result is a bar that moves at 60 fps while the main thread stays free for typing, scrolling and clicks.
Upload progress events arrive far more often than the screen can show them. Twelve concurrent XHRs can emit several hundred progress events per second, and a component framework that re-renders the whole list on each one spends most of its time diffing unchanged rows. Width-animated bars force layout on every change. The page stutters, the upload itself slows because the main thread handles events late, and on phones the fan spins. This page belongs to realtime upload progress events in frontend UX, chunking and progress tracking; the numbers it draws come from aggregating progress across multiple files.
When to use this approach
- Upload pages that show per-file bars for more than a handful of files.
- Any page where scrolling or typing becomes sluggish during uploads.
- Frameworks with component re-rendering (React, Vue, Svelte) where progress lives in reactive state.
Prerequisites
- Progress events per file from XHR or a chunked uploader.
- A list rendered from state, with stable element IDs per file.
- Chrome DevTools Performance panel (or Firefox Profiler) to measure before and after.
Events in, frames out
Implementation
Markup and CSS for a row whose bar never triggers layout:
.row__track { position: relative; height: 6px; width: 100%; overflow: hidden; border-radius: 3px; background: #e8ddd0; }
.row__fill {
position: absolute; inset: 0;
transform-origin: left center;
transform: scaleX(var(--p, 0));
background: #b34914;
will-change: transform;
}
@media (prefers-reduced-motion: no-preference) {
.row__fill { transition: transform 200ms linear; }
}
The renderer:
interface RowState { sent: number; size: number; status: string; shown: number }
export class ProgressRenderer {
private state = new Map<string, RowState>();
private dirty = new Set<string>();
private raf = 0;
private els = new Map<string, { fill: HTMLElement; pct: HTMLElement; status: HTMLElement; bar: HTMLProgressElement }>();
constructor(private list: HTMLElement) {}
register(id: string, size: number, row: HTMLElement) {
this.state.set(id, { sent: 0, size, status: "Waiting", shown: 0 });
this.els.set(id, {
fill: row.querySelector(".row__fill")!, pct: row.querySelector(".row__pct")!,
status: row.querySelector(".row__status")!, bar: row.querySelector("progress")!,
});
}
/** Called from progress handlers: O(1), no DOM. */
progress(id: string, sent: number) { const s = this.state.get(id); if (!s) return; s.sent = sent; this.mark(id); }
status(id: string, status: string) { const s = this.state.get(id); if (!s) return; s.status = status; this.mark(id); }
private mark(id: string) {
this.dirty.add(id);
if (!this.raf) this.raf = requestAnimationFrame(() => this.flush());
}
private flush() {
this.raf = 0;
for (const id of this.dirty) {
const s = this.state.get(id)!, el = this.els.get(id);
if (!el) continue; // row not rendered (virtualised) — state is kept
const target = s.size ? s.sent / s.size : 1;
s.shown = Math.max(s.shown, target); // never move backwards
el.fill.style.setProperty("--p", s.shown.toFixed(4));
const pct = Math.floor(s.shown * 100);
if (el.bar.value !== pct) el.bar.value = pct; // native element for assistive tech
const text = `${pct}%`;
if (el.pct.textContent !== text) el.pct.textContent = text;
if (el.status.textContent !== s.status) el.status.textContent = s.status;
}
this.dirty.clear();
}
unregister(id: string) { this.els.delete(id); }
}
Hooking it to uploads:
const renderer = new ProgressRenderer(document.querySelector("#file-list")!);
xhr.upload.onprogress = (e) => renderer.progress(fileId, chunkOffset + e.loaded);
xhr.onload = () => renderer.status(fileId, "Uploaded");
Line-by-line on the decisions that matter
- Event handlers only write numbers. Setting two properties and adding to a
Setcosts microseconds. Hundreds of events per second become negligible, and the network callbacks are never delayed by rendering. - One
requestAnimationFrameper frame. Whatever the event rate, the DOM is touched at most once per display frame, right before paint — the cheapest moment. - Dirty set. Only rows that changed since the last frame are updated. With twelve active uploads in a list of five hundred, a frame touches twelve rows, not five hundred.
transform: scaleX()via a custom property. Transforms are handled by the compositor and do not trigger layout or paint of surrounding content. Animatingwidthforces layout of the row, and often the list, on every change.- Short linear transition. Upload progress arrives in steps (each chunk or each event). A 200 ms transition smooths steps into continuous motion without lagging noticeably behind reality. Disabled under
prefers-reduced-motion. - Compare before writing text. Writing identical
textContentstill invalidates the node. Rounding to whole percents and checking equality means text updates at most a hundred times per file. - Native
<progress>kept in sync. The styled bar is decorative; the hidden-from-nobody<progress>gives screen readers the value on demand, as described in announcing upload progress to screen readers.
What each approach costs per frame
Frameworks: keep progress out of reactive state
In React, putting per-file progress into component state or a context re-renders every consumer on every event. Keep progress in an external store and subscribe with useSyncExternalStore, emitting at most once per frame; give each row its own subscription selecting only its file, so a change to one file re-renders one row. Better still for bars: let the row component render structure and status text, and let the renderer above update the bar’s custom property directly through a ref. Vue and Svelte have the same issue with deep reactivity on large arrays; mark the progress store as non-reactive (markRaw, a plain store outside components) and push frame-batched snapshots.
Whatever the framework, avoid keying list items by index. When a file is removed, index keys make every following row re-render and lose its element references; use the file’s stable ID.
Long lists
Batches of thousands of files — photo library imports, folder uploads — need virtualisation. Render only rows in the viewport plus a small overscan, and bind each rendered row to a file ID as it scrolls into view; the renderer’s register/unregister calls map directly onto a virtual list’s mount and unmount. Because state lives in the store, a newly visible row shows the current value on its first frame. For accessibility, keep the overall summary and the failure list outside the virtualised region, so screen-reader users can always reach the counts and the files that need action. Alternatively, collapse completed files into a single “1,842 uploaded” line and render only active, waiting and failed files in full.
Measuring the improvement
Measure before changing anything, so you know which fix matters. Record a Performance profile during a realistic upload — the number of files and the list length your users actually have — and look at three things: the share of each frame spent in scripting, whether “Recalculate Style” and “Layout” appear on every frame, and long tasks over 50 ms. Event storms show up as many small scripting slices; width-based bars show up as layout on every frame; framework re-renders show up as long scripting tasks inside the framework’s reconciliation. The Interaction to Next Paint metric in the Performance panel’s live view is the user-facing number to watch: clicks on Pause or Cancel during uploads should respond in well under 200 ms.
Repeat the recording after each change. Batching alone usually removes the event storm; switching to transforms removes layout; dirty-row updates remove the remaining scripting. On low-end Android devices the difference between the first and last profile is often the difference between an upload page that feels broken and one that feels native.
Configuration gotchas
Bars jump backwards after retries. The renderer displays raw sent, which drops when a chunk fails. Keep a monotonic shown value, as above.
Transitions lag far behind at the end. A long transition makes the bar trail reality and reach 100 % after the “Uploaded” label. Keep transitions at 150–250 ms, or remove them when the file completes.
Scrolling stutters although rendering is batched. Something else forces layout each frame — often reading offsetWidth or getBoundingClientRect inside the render loop. Avoid layout reads in flush.
Background tabs flood the renderer on return. requestAnimationFrame pauses in hidden tabs while events keep updating the store; on return, one frame renders the latest state. That is the desired behaviour — do not replay missed frames.
Verification
- Record a Performance profile while uploading 12 files in a 500-row list: frames stay green, scripting under a few milliseconds per frame.
- Enable “Paint flashing” in DevTools: only the bar and percent areas of active rows flash.
- Throttle CPU 6× and scroll the list during uploads: scrolling remains smooth.
- Check a screen reader reads the correct percentage from the native
<progress>of any row.
Frequently Asked Questions
Is a canvas-drawn progress list faster?
It can be, but it loses text selection, accessibility and responsive layout. With batching and transforms, DOM rendering is fast enough for any realistic upload list.
Should progress updates move to a Web Worker?
The network callbacks for XHR and fetch run on the main thread regardless. Workers help if you do heavy per-chunk work (hashing, compression); rendering remains a main-thread job, which is why keeping it cheap matters.
Is setInterval batching as good as requestAnimationFrame?
A 100 ms interval is acceptable and cheaper for many rows, but requestAnimationFrame aligns updates with paint and pauses automatically in background tabs, which intervals do not.