On this page
An AI-agent platform I work on as fractional CTO needed a feature that sounds simple: let users attach a ZIP file to a chat and have an AI agent analyze its contents. The catch: the attachment limit needed to be 500 MB — and the entire API runs in Cloudflare Workers, where an isolate gets 128 MB of memory, total, shared with everything else it’s doing.
You cannot buffer a 500 MB file in a 128 MB runtime. You can’t even buffer a 100 MB file safely once you account for the bundle, the database clients, and the other requests sharing the isolate. Having already lived through a week of memory-kill archaeology on this exact platform, we were not going to ship a feature whose happy path was “hold user bytes in Worker memory.”
The design that works is old and unglamorous: split the control plane from the data plane.
The principle
- The control plane decides whether a transfer may happen: authentication, authorization, quotas, metadata. This is what Workers are excellent at — small, fast, stateless decisions.
- The data plane moves the bytes: object storage talking directly to whoever needs the bytes.
The rule that falls out of it: Workers move authorization decisions. R2 moves bytes. Any design where file content flows through the function is a bug waiting for a big file.
How the upload path works
The client never POSTs the file to the API. Instead:
- The client asks the API to start an upload, sending metadata (name, size, type).
- The Worker checks permissions and limits, records the pending document, and returns upload credentials scoped to a single object key.
- The client streams the file directly to R2.
- The client confirms completion; the Worker verifies and marks the document ready.
The Worker’s involvement is a few kilobytes of JSON in each direction. Whether the file is 5 MB or 500 MB, the API’s cost is identical.
How the download path works — presigned URLs with WebCrypto
The harder half is downstream. The analysis doesn’t happen in the Worker either — it happens in sandboxed containers on separate machines, and they need the bytes.
The wrong design is the intuitive one: the sandbox asks the API for the file, the API reads it from R2 and forwards it. That routes every byte through the Worker again, just in the other direction.
Instead, the Worker generates a presigned R2 GET URL and hands it to the sandbox manager. R2 is S3-compatible, so this is standard AWS Signature Version 4 presigning — and the whole signing dance (HMAC-SHA256 key derivation, canonical request hashing) is implementable directly with the WebCrypto API available in Workers. No SDK, no dependency weight in an already-large bundle.
The URL carries a 1-hour TTL — long enough for a slow pull of a large archive, short enough that a leaked URL has a small window. The sandbox pulls straight from R2 with plain HTTP. The Worker that authorized the transfer has already returned and been recycled by the time the first byte moves.
The failure the design review caught
The part of this story I find most worth sharing is a problem we almost shipped.
The data plane has memory budgets too. The sandbox manager was configured to pull multiple archives concurrently — with a default of 3 concurrent pulls at up to 512 MB each. A design review flagged the obvious-in-hindsight arithmetic: three simultaneous large pulls could exhaust the puller’s memory just as surely as buffering in the Worker would have, unless pulls are streamed to disk and concurrency is bounded by available memory rather than by a hopeful constant.
Moving bytes out of the serverless tier doesn’t make them free. It moves the memory question to a machine where you can at least answer it — with disk, backpressure, and explicit limits. You still have to answer it.
The general rules
This pattern generalizes far beyond ZIP files:
- Size-gate before reading, not after. Check declared and actual sizes before any code path that materializes content.
- Stream, never buffer. In a Worker,
new Response(r2Object.body)forwards a stream;await r2Object.arrayBuffer()gambles the isolate. On the container side, pull to disk, not to RAM. - Short TTLs on presigned URLs, single-purpose keys. A presigned URL is a bearer credential; scope and expire it like one.
- Budget the data plane explicitly. Concurrency × max-object-size must fit the machine, and the limit should live in config, not in luck.
- Keep the control plane boring. If a request handler’s memory usage depends on user input size, the design is wrong — regardless of platform.
Serverless platforms make the control plane nearly free and make the data plane nearly impossible. The mistake is asking one tier to be both. Split them, and a 500 MB upload becomes exactly as scary as a 5 KB one — which is to say, not at all.