EAZIP BLOG
3 Ways to Download All Files From Cloudflare R2 as a ZIP
R2 has no built-in 'download folder as ZIP.' Here's how rclone, a Worker-side zip stream, and a browser-based streaming library each handle it — with honest tradeoffs.
Cloudflare R2 is popular for user-facing file storage for one big reason: no egress fees. You can serve gigabytes of user uploads, exports, or generated reports without a bandwidth bill scaling alongside usage. But R2's S3-compatible API only gives you single-object GET requests — there's no GET /folder?format=zip. If your product has a "download all" button, something has to fetch every object and bundle it, and R2 doesn't do that part for you.
This post compares three ways people actually solve it: a CLI tool for one-off or admin use, a Worker that streams a ZIP through Cloudflare's edge, and a browser-based library that builds the archive client-side. Each has a different failure mode once the folder gets big.
At a glance
| Approach | Works well for | Breaks down at | Infra needed | Cost notes |
|---|---|---|---|---|
| rclone / S3 CLI | Admin exports, migrations, backups | End-user, in-product download | A machine to run it | Free tool; R2 has no egress fee either way |
| Zip in a Worker | Small-to-medium folders, full control over format | Large archives (128 MB isolate memory, CPU time budget) | A Worker + R2 binding, a zip-stream implementation | Worker request/CPU pricing on top of R2 |
| Zip in the browser (Eazip) | In-product "download all," zero server bandwidth | Very large jobs need Cloud offload | A presigning endpoint + a client library | R2 requests only; Eazip pricing for Cloud jobs |
Way 1: rclone or an S3-compatible CLI
R2 speaks the S3 API, so any S3 tool works against it. rclone is the common choice:
rclone config create r2 s3 provider=Cloudflare \
access_key_id=... secret_access_key=... \
endpoint=https://<account_id>.r2.cloudflarestorage.com
rclone copy r2:my-bucket/reports-2026-q2 ./reports-2026-q2
zip -r reports-2026-q2.zip reports-2026-q2aws s3 sync with a custom --endpoint-url does the same thing. Either way you get a full local mirror of the prefix, then zip it with a normal desktop tool.
Verdict: this is the right tool for what it's built for — someone with R2 credentials pulling a bucket or prefix to a machine they control. It is not a "download all" button for end users: it needs storage credentials, a shell, and local disk, none of which belong in a product's frontend. Use it for internal exports, migrations, and backups, not for a customer-facing feature.
Way 2: Zip inside a Cloudflare Worker
If you want the ZIP built at the edge, a Worker with an R2 bucket binding can list objects, fetch each one, and stream ZIP entries out as the response body using a streaming zip implementation (there's no built-in zip API, so you're pulling in something like a ZIP writer that works against ReadableStream):
export default {
async fetch(request: Request, env: Env) {
const prefix = new URL(request.url).searchParams.get('prefix')!;
const listed = await env.BUCKET.list({ prefix });
const { readable, writable } = new TransformStream();
streamZipEntries(listed.objects, env.BUCKET, writable); // your zip-stream logic
return new Response(readable, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="export.zip"',
},
});
},
};Verdict: doable, but you own every edge case. Two Workers limits shape this directly. Memory is capped at 128 MB per isolate — that's shared across the JS heap and any WebAssembly your zip library uses, so buffering more than a modest amount of any single object (or holding many open streams at once) risks an out-of-memory kill. CPU time is metered separately from wall-clock time: the default budget is 30 seconds per request even on paid plans (extendable up to 5 minutes), and CPU time only counts active computation — but compressing many files is exactly that. A response can keep streaming as long as the client stays connected, so wall-clock duration alone isn't the constraint; the CPU and memory ceilings are. For a folder of a few dozen small files this is manageable. For thousands of objects or multi-gigabyte totals, you're implementing your own resumability, backpressure, and retry logic against those ceilings — real engineering, not a weekend script. (Verify current numbers against Cloudflare's Workers limits docs before you build against them; limits change.)
Way 3: Zip in the browser with a streaming library
The third option skips server-side zipping entirely: your backend only lists and signs URLs, and the browser fetches R2 directly and builds the archive on the visitor's own device. We built Eazip for this pattern.
import { createZip } from '@eazip/core';
const files = await fetch('/api/exports/reports-2026-q2/files').then((r) =>
r.json(),
); // [{ url, filename }, ...] — signed R2 URLs from your server
const result = await createZip({
files,
zipName: 'reports-2026-q2.zip',
});
result.download();This pairs naturally with R2's zero-egress pricing: the bytes travel from R2 to the visitor's browser directly, your application server never touches them, and you're not paying compute to compress on a Worker either. You do need to configure R2's CORS policy so the browser is allowed to fetch signed URLs from a different origin, and you're now depending on a third-party library in your bundle rather than code you fully own.
The DIY version of this approach is JSZip in the browser, and it's worth naming why teams move off it: JSZip builds the whole archive in memory before you can save it, so a few hundred megabytes of source files can exhaust tab memory before the download even starts. Streaming approaches (Eazip included) write ZIP entries as they arrive instead of buffering everything, which is what makes multi-gigabyte folders survive in a browser tab at all — though a browser tab still has real memory and lifetime limits, which is why very large or very numerous-file jobs get offloaded to a cloud worker rather than run entirely client-side.
Verdict: best fit when the goal is a genuine end-user "download all" button and you want zero bytes touching your server. The tradeoffs are CORS setup, a dependency, and an offload path (like Eazip Cloud, or your own server-side fallback) once a job is too large or long-lived for one browser tab.
Which one to pick
- Running this once, or you're an admin, not a product feature: rclone or the S3 CLI. Nothing to build.
- You already run a Worker in front of R2 and archives stay small: a Worker-side zip stream keeps everything in one place, as long as you're comfortable owning the memory/CPU edge cases yourself.
- This is a customer-facing "download all" and you want to keep bytes off your server: zip in the browser. Eazip's guide below covers presigning and CORS end to end, and Cloud handles the cases too big for a tab.
For the full presigning-and-CORS walkthrough, see Download Cloudflare R2 Objects as a ZIP. For when a job outgrows a single browser tab, see When to use Eazip Cloud.
FAQ
How do I download all files in an R2 bucket at once?
For a full bucket or a large prefix you control, rclone copy (or aws s3 sync pointed at R2's endpoint) mirrors it locally, then a normal zip command archives the folder. For an in-product "download all" button, list and presign the objects on your server and zip them client-side instead — see the full guide.
Can I zip an R2 bucket into one file without downloading it first?
Yes, two ways: run a Worker with an R2 binding that streams ZIP entries as it reads each object (you own the memory and CPU limits), or presign the objects server-side and build the ZIP in the visitor's browser, which never touches your server's disk or bandwidth at all.
Is there a bulk download feature built into R2?
No. R2's dashboard and S3-compatible API operate on individual objects; there's no native "download folder as ZIP." You have to fetch objects and archive them yourself, either with a CLI tool, a Worker, or client-side code.
Does zipping R2 files cost extra in egress fees?
No — R2 doesn't charge egress regardless of which approach you use. You still pay R2's normal per-request pricing for the LIST and GET calls involved, and any compute cost specific to your chosen approach (a Worker's CPU time, or none at all if the browser does the fetching).