A 100MB video took 460MB of RAM in my serverless function. A 1GB one killed it outright. The fix was not more memory — it was never holding the file at all.
Here are the actual numbers, because I assumed the wrong bottleneck for weeks.
The symptom
My app forwards videos to TikTok and YouTube on a user's behalf. It had a hard 80MB limit on incoming video URLs, and the error message said so:
media too large (max 80MB)
TikTok accepts 4GB. YouTube accepts far more. So the ceiling was mine, and someone was going to ask why — which is exactly what happened.
My assumption: the function has a 60-second window, and video is big, so 80MB must be roughly what fits in the time. Reasonable. Wrong.
Measuring instead of guessing
I put a temporary probe in the function that downloads a URL and reports size, wall time and memory.
const t0 = Date.now();
const r = await fetch(url);
const buf = Buffer.from(await r.arrayBuffer());
res.json({
mb: buf.length / 1048576,
seconds: (Date.now() - t0) / 1000,
rss_mb: process.memoryUsage().rss / 1048576,
});
Three runs, three surprises:
| test | result |
|---|---|
| function that sleeps 70s | dies at 60.5s — so the platform limit was real and exact |
| download 100MB | 1.3 seconds — about 77 MB/s |
| memory while holding that 100MB | 460 MB RSS |
| download 1GB | FUNCTION_INVOCATION_FAILED |
The bandwidth number is the one that reframed everything. 100MB arrives in 1.3 seconds. Even a full gigabyte is about 13 seconds of transfer inside a 60-second budget. Time was never the constraint.
Memory was. And notice the ratio: a 100MB file cost 460MB of resident memory — roughly 4.5×. That is because Buffer.from(await r.arrayBuffer()) materialises the body twice: once as an ArrayBuffer, once as a copy in a Buffer. Add the runtime's own overhead and a 1GB file blows through a 1GB memory limit long before it finishes.
So the 80MB ceiling was not protecting me from the clock. It was protecting me from my own allocation, and it was set conservatively on top of that.
The fix: never hold the file
Both APIs accept chunked uploads. If you read from the source stream and push each chunk as soon as it is complete, peak memory is one chunk, regardless of whether the video is 50MB or 900MB.
const reader = response.body.getReader();
let pending = [], pendingLen = 0, sent = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
pending.push(Buffer.from(value));
pendingLen += value.length;
if (pendingLen >= CHUNK) {
const body = Buffer.concat(pending, pendingLen);
await uploadChunk(body, sent); // PUT with a Content-Range
sent += body.length;
pending = []; pendingLen = 0;
}
}
if (pendingLen) await uploadChunk(Buffer.concat(pending, pendingLen), sent);
Same probe, after the change:
| before (buffered) | after (streamed) | |
|---|---|---|
| 100 MB | 460 MB RSS | 132 MB RSS |
| 1 GB | function died | 142 MB RSS, 11.5 s |
A full gigabyte now moves through in under twelve seconds while memory stays flat.
Three details that will bite you
1. Both APIs demand the exact byte count before you send a single byte.
TikTok's init call needs video_size. YouTube's resumable init needs X-Upload-Content-Length. You cannot discover the size while streaming — you need it up front, from the source's Content-Length header.
If the host does not send one, you cannot stream at all, and you are back to buffering. I kept that path and capped it explicitly, with an error that says why rather than just refusing:
that host does not report a file size, so the video has to be held in memory and cannot exceed 80MB
2. The chunk rules differ, and the last chunk is where it breaks.
TikTok: each chunk must be 5–64MB, and the final chunk may exceed chunk_size (up to 128MB). So the count is floor(size / chunk), and the last chunk absorbs the remainder.
Use ceil and you will eventually produce a final chunk under 5MB, which TikTok rejects. That bug only appears at certain file sizes, which is a miserable way to find it in production. I caught it with a plain loop over sizes from 3MB to 1GB, asserting each chunk against the documented bounds — thirty seconds of test code for a bug that would have looked random.
YouTube: every chunk except the last must be a multiple of 256KB. I use 32MB, which is 128 × 256KB. Because network reads arrive in irregular sizes (64KB, 17KB, sometimes 900 bytes), you have to accumulate to an aligned boundary and carry the remainder into the next chunk rather than sending whatever happens to have arrived.
3. YouTube answers 308 for every chunk but the last.
308 Resume Incomplete means success, keep going. But fetch reports response.ok === false for it. Treat it as an error and you abort the upload precisely when it is working:
if (put.status === 308) return false; // more to send — this is success
What I would take from this
The instinct to blame the clock in a serverless function is strong, and it sent me down the wrong path for weeks. One probe with process.memoryUsage() and a timer settled it in ten minutes and pointed at a completely different fix.
Also: I removed that probe the moment it gave me the numbers. It did fetch(url) on any URL passed to it, which is an open SSRF — my server would fetch anything anyone asked it to. If you build one, validate the host or delete it the same day.
The limit went from 80MB to 1GB on both networks. I write PostWire, which publishes one draft natively to every social network, so a customer hitting an invented size cap was a promise I did not want to keep making.
Top comments (0)