Download Multiple Files at Once in JavaScript
Why the loop of anchor clicks fails, what browsers actually allow, and the reliable pattern — packaging the files into one ZIP download client-side.
"Just trigger a download per file" is everyone's first idea, and browsers are built to defeat it. This page covers what actually happens when you try, and the pattern that works reliably: one ZIP, one download, built in the page.
Why the anchor loop fails
// The intuitive approach — and its problems
for (const url of urls) {
const a = document.createElement('a');
a.href = url;
a.download = suggestedName(url);
a.click();
}- Browsers throttle multi-download bursts. One user gesture earns roughly one download; the rest trip the popup/download blocker or arrive haphazardly, varying by browser and settings.
downloadrenaming doesn't cross origins. For cross-origin URLs the attribute is ignored, so your carefully suggested filenames vanish.- The user gets N files, not a folder. Twenty loose files in
Downloads/— some duplicated asreport (3).pdf— is the experience you were trying to avoid. - Sequential tricks (delays between clicks, iframe farms) trade one unreliability for another and still fail the folder problem.
The File System Access API (showDirectoryPicker) can write many files
with permission, but it's Chromium-only and permission-gated — a fallback
still has to exist.
The reliable pattern: one ZIP
Package the files client-side and trigger a single download — one user gesture, one file, real filenames, folder structure preserved:
import { createZip } from '@eazip/core';
const result = await createZip({
files: [
{ url: '/reports/q1.pdf', filename: 'reports/q1.pdf' },
{ url: '/reports/q2.pdf', filename: 'reports/q2.pdf' },
localNotesBlob && { file: localNotesBlob, filename: 'notes.txt' },
].filter(Boolean),
zipName: 'documents.zip',
});
result.download();Eazip.js is MIT-licensed, runs entirely in the page, and accepts files, blobs, and URLs mixed in one array. Remote URLs must be fetchable from the page — same-origin, public with CORS, or signed URLs from your server (Inputs and sources has the rules).
Building this in React? The complete component — button, progress tray, partial results — is in Download multiple files as a ZIP in React. Letting users pick which files? See Download selected files.
When a single file per click is actually fine
Honesty about the boundary: if the normal case is one or two files, a plain anchor per gesture works and needs no library. The ZIP pattern earns its place when "download all" means many files, when filenames and folder structure matter, or when the sources are a mix of local and remote.
And when the file set is huge — gigabytes, thousands of URLs — the
browser itself becomes the wrong place to assemble it; the same call moves
to managed Eazip with strategy: 'cloud'
(Scale with Eazip).
FAQ
Can I download multiple files without zipping them?
Within limits: one download per real user gesture is dependable, and the File System Access API can write a folder in Chromium browsers after a permission prompt. Neither gives a portable "click once, get everything" — that's what the ZIP is for.
Do the files pass through a server?
No — on the Local path the page fetches the sources and builds the archive in memory. Nothing is uploaded anywhere.
Why do my cross-origin files lose their filenames with plain anchors?
The download attribute only applies same-origin (or to blob/data URLs).
Inside a ZIP the names are yours unconditionally — each entry's
filename is written into the archive.
What if one of the URLs fails?
The default keeps the usable output: the ZIP ships without the failed file and the failure is reported, rather than the whole download dying — see Partial results and errors.
Create a ZIP File in the Browser with JavaScript
Turn browser files, blobs, and URLs into a ZIP entirely client-side — one createZip call, no server, no upload, with the options that actually matter.
Download Multiple Files as a ZIP in React
Build a production "Download all" button with @eazip/react — fetch the file list, start the job with useEazip(), and let <EazipTray /> handle progress, partial results, and retry.