Building a "Download All as ZIP" button in React sounds like a weekend project. You grab a library, point it at a list of file URLs, and ship it. That works fine when your files are a few PDFs and some images. The moment someone tries to download 2GB worth of video assets, the whole thing explodes.
I've been through this cycle more than I'd like to admit. Browser tabs freezing, memory usage spiking to 4GB, users clicking the button three times because nothing seemed to happen. So here's what I've learned about making this actually work at scale, with progress feedback, cancellation, and retry logic that doesn't suck.
Why the Naive Approach Breaks
The first instinct is to fetch everything into memory, then zip it. Something like this:
const files = await Promise.all(
urls.map(url => fetch(url).then(r => r.blob()))
);
const zip = new JSZip();
files.forEach((blob, i) => zip.file(names[i], blob));
const content = await zip.generateAsync({ type: 'blob' });
saveAs(content, 'archive.zip');Clean, readable, and completely broken for large downloads—a pattern similar to hydration overhead in React apps where invisible work tanks perceived performance only once real users hit it. Promise.all fetches everything in parallel, so all those blobs sit in memory at the same time. For a handful of 5MB files, you're fine. For fifty 100MB videos? Your tab is dead. The browser caps out around 2-4GB of heap space for a single page, and you'll hit that wall fast.
There's also zero feedback. The user clicks a button, stares at nothing for two minutes, and wonders if it's working.
Streaming ZIP Generation
The fix is to never hold everything in memory at once. Process files one at a time, stream each into the ZIP writer, and discard the data as you go.
The browser has a built-in answer for this: the Compression Streams API combined with ReadableStream. But the API is low-level enough that building a ZIP file from scratch with proper headers, central directory entries, and CRC32 checksums is a pain. Libraries handle this better.
Two solid options:
@nicordev/zip-stream- lightweight, streams chunks to aWritableStreamfflate- fast, supports async streaming withZipPassThroughobjects
I lean toward fflate for anything production. It's fast, handles ZIP64 (files over 4GB), and the streaming API lets you process one file at a time.
Here's the core pattern:
import { Zip, ZipPassThrough } from 'fflate';
async function downloadAsZip(files, onProgress, signal) {
const zip = new Zip((err, data, final) => {
if (err) throw err;
// data is a Uint8Array chunk
chunks.push(data);
if (final) {
const blob = new Blob(chunks, { type: 'application/zip' });
saveBlob(blob, 'archive.zip');
}
});
for (const file of files) {
if (signal?.aborted) throw new Error('Cancelled');
const entry = new ZipPassThrough(file.name);
zip.add(entry);
const response = await fetch(file.url, { signal });
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
entry.push(value, false); // false = not final chunk
}
entry.push(new Uint8Array(0), true); // true = done with this entry
}
zip.end();
}One file at a time. Each chunk goes into the ZIP writer, the writer compresses and emits output chunks, and we never hold more than the current chunk in memory. Memory usage stays flat regardless of total archive size.
Adding Meaningful Progress
Users need two things: "how much is done" and "is this still running." Generic spinners don't cut it when the download might take five minutes.
The trick is layered progress. Track it at two levels:
- File-level - which file out of N are we on
- Byte-level - how many bytes of the current file have been downloaded
To get total byte progress, you need Content-Length headers from your server. Not every endpoint returns them (chunked transfer encoding won't), but most static file servers do.
async function fetchWithProgress(url, signal, onBytes) {
const response = await fetch(url, { signal });
const total = parseInt(response.headers.get('Content-Length') || '0', 10);
const reader = response.body.getReader();
let loaded = 0;
const stream = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
loaded += value.byteLength;
onBytes(loaded, total);
controller.enqueue(value);
},
});
return new Response(stream);
}Then wire it up at the file level:
for (let i = 0; i < files.length; i++) {
const file = files[i];
const response = await fetchWithProgress(
file.url, signal,
(loaded, total) => {
onProgress({
fileIndex: i,
fileName: file.name,
fileLoaded: loaded,
fileTotal: total,
overallPercent: ((i + loaded / total) / files.length) * 100,
});
}
);
// ...stream into zip...
}That gives you a progress bar that moves smoothly per-file and shows overall completion. You can display "Downloading file 3 of 12: video-clip.mp4 (45%)" which is way more useful than a spinner.
Cancellation With AbortController
This one's non-negotiable for large downloads. If someone accidentally clicks "Download All" on a folder with 4GB of assets, they need an escape hatch.
Wire up AbortController at two points: the fetch calls and the overall loop.
const controller = new AbortController();
// In your React component
const [abortController, setAbortController] = useState(null);
const handleDownload = () => {
const ctrl = new AbortController();
setAbortController(ctrl);
downloadAsZip(files, setProgress, ctrl.signal)
.catch(err => {
if (err.name === 'AbortError') {
console.log('Download cancelled');
}
});
};
const handleCancel = () => {
abortController?.abort();
};Passing signal into fetch() kills the HTTP request immediately. The signal.aborted check in the loop prevents starting the next file. The browser reclaims the partial memory. Clean exit.
One thing people miss: you also need to close the ZIP stream when cancelling. If you don't, the fflate internal state hangs around. Wrap the whole thing in a try/finally that calls zip.end() regardless of how it exits.
Retry Logic That Doesn't Reset Everything
Network hiccups happen. A 200MB file download that fails at 180MB shouldn't force a full restart.
For individual files, you can implement simple retry with backoff:
async function downloadFileWithRetry(url, signal, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fetchWithProgress(url, signal, onBytes);
} catch (err) {
if (err.name === 'AbortError') throw err; // Don't retry cancellation
if (attempt === maxRetries) throw err;
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
}
}
}The progress counter resets per attempt, which isn't ideal but it's honest. If you want byte-level resume, you'd need server support for Range headers. Most CDNs and object storage services (S3, R2, GCS) support them out of the box:
const headers = {};
if (resumeAt > 0) {
headers['Range'] = `bytes=${resumeAt}-`;
}
const response = await fetch(url, { headers, signal });But implementing full resume across the entire ZIP archive is complex because ZIP format requires sequential entries. The pragmatic approach: retry individual files up to 3 times, and only fail the whole download if a file fails all retries.
The React Hook
Putting it all together, here's a clean hook:
function useZipDownload() {
const [state, setState] = useState({
status: 'idle', // 'idle' | 'downloading' | 'done' | 'error' | 'cancelled'
progress: null,
error: null,
});
const controllerRef = useRef(null);
const download = useCallback(async (files) => {
const ctrl = new AbortController();
controllerRef.current = ctrl;
setState({ status: 'downloading', progress: null, error: null });
try {
await downloadAsZip(
files,
(p) => setState(s => ({ ...s, progress: p })),
ctrl.signal
);
setState({ status: 'done', progress: null, error: null });
} catch (err) {
const status = err.name === 'AbortError' ? 'cancelled' : 'error';
setState({ status, progress: null, error: err.message });
}
}, []);
const cancel = useCallback(() => {
controllerRef.current?.abort();
}, []);
return { ...state, download, cancel };
}Usage in a component:
function DownloadPanel({ files }) {
const { status, progress, download, cancel } = useZipDownload();
return (
<div>
{status === 'idle' && (
<button onClick={() => download(files)}>
Download All as ZIP
</button>
)}
{status === 'downloading' && (
<>
<ProgressBar percent={progress?.overallPercent} />
<p>{progress?.fileName} ({progress?.fileLoaded} / {progress?.fileTotal})</p>
<button onClick={cancel}>Cancel</button>
</>
)}
{status === 'done' && <p>Download complete</p>}
{status === 'error' && (
<>
<p>Download failed. <button onClick={() => download(files)}>Retry</button></p>
</>
)}
{status === 'cancelled' && (
<button onClick={() => download(files)}>Start Over</button>
)}
</div>
);
}Things That Bite You in Production
A few gotchas I've hit that aren't obvious until users start complaining:
Service Worker interference. If your app uses a service worker for caching, it might try to cache multi-GB fetch responses. That tanks performance and storage. Exclude your download endpoints from the SW cache rules.
Safari memory pressure. Safari on iOS is more aggressive about killing tabs than Chrome. For Safari, keep chunk sizes small (64KB is safe) and process files sequentially. No parallel fetching, even for small files.
Filename encoding. Non-ASCII filenames in ZIP headers need special handling. fflate handles UTF-8 in filenames correctly, but some older ZIP clients on Windows might display garbled names. If your user base uses older Windows machines, consider a filename sanitization step.
Web Workers. If you want to keep the main thread completely free (no jank during compression), offload the fflate work to a Web Worker. It adds complexity with message passing for chunks, but for apps where UI responsiveness matters during a long download—main-thread blocking directly impacts Core Web Vitals like Interaction to Next Paint—it's worth it.
When to Not Do This in the Browser
There's a ceiling. If your users regularly download 5GB+ archives, or if files need server-side processing before zipping (watermarking, format conversion), the browser isn't the right place. Generate the ZIP on the server, upload it to object storage with a signed URL, and give the user a simple download link with an expiration timer.
Client-side zipping is best when files already exist as-is, the total size is under 2-3GB, and you want to avoid server compute costs and temp storage. For everything else, push it server-side.
The approach above handles the middle ground well: archives in the hundreds of megabytes to low gigabytes, with proper user feedback so nobody's left guessing if the download is stuck. That's the sweet spot for most web apps.



