Back to News & Insights
JavaScript October 16, 2026 · 5 min read

Copy a remote video into R2 from a Cloudflare Worker without buffering it

If your Worker copies files it didn't create (AI generation results, imports from a URL, exports from...

Copy a remote video into R2 from a Cloudflare Worker without buffering it

If your Worker copies files it didn't create (AI generation results, imports from a URL, exports from another service), the obvious line, await res.arrayBuffer(), loads the whole file into an isolate whose 128 MB is shared by every request it is serving. For video that's the wrong default. You can stream the file straight from the source into R2 instead, and it takes three pieces: FixedLengthStream, pipeTo, and an S3 PUT signed as UNSIGNED-PAYLOAD.

The code below is trimmed from the copy step at Movoa, which moves each finished video from a provider's temporary URL into our own bucket.

TL;DR Workers memory is 128 MB per isolate, not per request, so buffered copies add up across concurrent requests. Stream when the source sends Content-Length: response.body.pipeTo(new FixedLengthStream(length).writable) and PUT the readable side. Sign the stream with aws4fetch; it uses UNSIGNED-PAYLOAD, which R2's S3 API accepts over HTTPS. Reject tiny bodies before writing, and fall back to buffering only when there's no length (or you're in Node).

Cloudflare's limits page (read 2026-10-16) is specific about why this bites: each isolate "can consume up to 128 MB of memory", and "this limit is per-isolate, not per-invocation. A single isolate can handle many concurrent requests." When an isolate goes over, in-flight requests are allowed to finish and new requests go to a fresh isolate; under heavy load some incoming requests can be cancelled.

So the risk isn't one big file. It's two or three ordinary video files being copied by the same isolate at the same moment, each fully materialised in memory.

A single S3 PUT to R2 wants a Content-Length. A raw ReadableStream doesn't carry one, which is where FixedLengthStream comes in. It's a Workers runtime TransformStream that you construct with the expected byte count: it tells the runtime the body's length up front, and it errors if the bytes that flow through don't match. That second property is a truncation check: if the source sends fewer than length bytes, the stream errors and the upload fails instead of leaving half a file in your bucket.

Two details matter. The pipeTo promise is started but not awaited before the PUT, because the PUT is what drains the readable side; awaiting the pipe first would deadlock. And the feature check on globalThis.FixedLengthStream keeps the same module working under Node in local dev, where that class doesn't exist (more on that below).

R2 speaks the S3 API, so the PUT is a signed request. SigV4 normally includes a hash of the payload, which is impossible for bytes you haven't received yet. aws4fetch handles this by signing a stream body as UNSIGNED-PAYLOAD, and R2 accepts that over HTTPS:

Checks before you write anything A 200 that is really an error. Some providers answer the result URL with a short JSON error and a 200 status when a file isn't ready or has expired. A minBytes threshold checked against Content-Length catches that before anything is written; cancel the body so the connection is released. Redirects and auth. Signed result URLs often redirect to object storage. Set redirect: 'follow' and pass through whatever headers the provider requires. Timeouts. AbortSignal.timeout(ms) on the fetch keeps a stalled source from holding the request open indefinitely. We give the whole download-and-upload 100 seconds.

If your Worker has the bucket bound (env.BUCKET), put() accepts a ReadableStream too. The catch is the same: the stream needs a known length, which a fetch() response body with a Content-Length has, and the readable side of a FixedLengthStream has. So await env.BUCKET.put(key, response.body) streams when the source sends a length; for anything else, wrap it in FixedLengthStream first.

We use the S3 API instead because the same storage module also runs under Node (local dev and scripts), where there is no binding, and one signed-request path serves both.

Two cases still take the buffered path: The source sends no Content-Length (chunked transfer). Without a length there's nothing to give FixedLengthStream, and a single PUT can't be sent. Buffering is the simple answer; for large files, a multipart upload with fixed-size parts is the scalable one. Local development under Node (next dev, next start), where FixedLengthStream isn't defined. The feature check routes these to arrayBuffer(), which is fine on a laptop.

Checklist Check response.ok and Content-Length before touching storage. On workerd with a known length: pipeTo into new FixedLengthStream(length), PUT the readable side, then settle the pipe promise. Sign with aws4fetch (UNSIGNED-PAYLOAD); send Content-Length explicitly. Reject bodies under a sane minimum; follow redirects; set a timeout. Buffer only as the fallback, and treat it as the path that costs memory.

On the streaming branch the file never sits in your isolate's memory, and a short read fails loudly instead of saving half a video. The buffered branch is the exception you keep for chunked sources and local dev.

The videos this copies come from the Movoa video generator, which turns one sentence or a photo into a clip with sound; the first one is free and needs no account.

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation