Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Web Development

The Download-as-ZIP Button That Crashes Your Users' Browsers

Learn how to implement a robust React download multiple files zip browser feature that handles massive archives with progress tracking and cancellation.

Dian Rijal Asyrof/August 11, 2026/6 min read
Illustration for The Download-as-ZIP Button That Crashes Your Users' Browsers

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 a WritableStream
  • fflate - fast, supports async streaming with ZipPassThrough objects

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:

  1. File-level - which file out of N are we on
  2. 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.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleNobody Budgets for the Real Cost of an AI AgentNext articleOpenAI Trained a Model to Hunt Hackers, Here's What Daybreak Actually Does
ReactJavaScriptZipFile DownloadFrontendBlob
On this page↓
  1. Why the Naive Approach Breaks
  2. Streaming ZIP Generation
  3. Adding Meaningful Progress
  4. Cancellation With AbortController
  5. Retry Logic That Doesn't Reset Everything
  6. The React Hook
  7. Things That Bite You in Production
  8. When to Not Do This in the Browser

On this page

  1. Why the Naive Approach Breaks
  2. Streaming ZIP Generation
  3. Adding Meaningful Progress
  4. Cancellation With AbortController
  5. Retry Logic That Doesn't Reset Everything
  6. The React Hook
  7. Things That Bite You in Production
  8. When to Not Do This in the Browser

See also

Illustration for Optimizing Core Web Vitals in Next.js App Router: LCP, FID, and CLS Practical Fixes
Web Development/Jul 30, 2026

Optimizing Core Web Vitals in Next.js App Router: LCP, FID, and CLS Practical Fixes

A comprehensive guide on diagnosing and fixing Largest Contentful Paint, First Input Delay, and Cumulative Layout Shift issues specifically within the Next.js App Router paradigm.

8 min read
Next.jsReact
Illustration for Next.js 15 Partial Prerendering in Production
Web Development/Jul 15, 2026

Next.js 15 Partial Prerendering in Production

Learn how to configure, deploy, and optimize Next.js 15 Partial Prerendering (PPR) in production environments for faster load times.

4 min read
Next.jsReact
Illustration for The Hydration Cost: Why React Apps Feel Slow on Startup
Web Development/Jul 7, 2026

The Hydration Cost: Why React Apps Feel Slow on Startup

Under the hood of hydration in React. Discover how server-side rendering loads pages, why hydration causes startup lag, and how to optimize it.

5 min read
ReactNext.js