Uploading past the serverless body limit.
A Vercel function accepts about 4.5 MB per request. Balti uploads videos and zips to CDN buckets anyway, without a separate upload server.
Balti’s API is Next.js route handlers on Vercel. That keeps the product to one deploy, and it comes with two hard numbers: a request body tops out at about 4.5 MB, and a function runs for at most 300 seconds. Balti’s users upload product videos, font packs and zipped asset folders. Many of those are much bigger than 4.5 MB.
I did not want a second service just for uploads. So uploads take one of two paths, and the browser falls from the first to the second on its own.
Path one: straight to the bucket
Every upload first asks the API for a presigned PUT URL. The route checks the session, the role and access to that connection, snapshots the file it is about to replace so the change can be undone, and signs a URL for exactly that key. The browser then sends the bytes directly to the bucket. They never touch the function, so the body limit does not apply. A single PUT carries up to 5 GB on most providers.
The catch is the browser. A PUT from Balti’s origin to the bucket’s origin is a cross‑origin request, and buckets refuse it unless their CORS rules allow it. Asking every admin to hand‑write CORS JSON for their bucket would have killed adoption, so Balti adds the rule itself when a connection is saved.
const alreadyAllowed = existing.some(
(rule) =>
(rule.AllowedOrigins?.includes(origin) || rule.AllowedOrigins?.includes('*')) &&
rule.AllowedMethods?.includes('PUT')
);
if (alreadyAllowed) return true;
await client.send(
new PutBucketCorsCommand({
Bucket: connection.bucket,
CORSConfiguration: {
CORSRules: [
...existing,
{
AllowedOrigins: [origin],
AllowedMethods: ['PUT', 'GET', 'HEAD'],
AllowedHeaders: ['*'],
ExposeHeaders: ['ETag'],
MaxAgeSeconds: 3600,
},
],
},
})
);
It reads the existing rules first and appends to them, scoped to Balti’s own origin. Replacing the bucket’s CORS configuration would have broken whatever else was relying on it, and a wildcard origin would have been a gift to anyone else’s page.
Path two: through the API, in pieces
Some keys are not allowed to change CORS, and some providers do not support setting it through the API. For those buckets the direct PUT fails, and the browser falls back to sending the file through Balti.
A file under 4 MB goes up as one request. Anything larger is sliced in the browser into 4 MB chunks, sent three at a time. Each chunk lands in the bucket under a staging prefix, named by an upload id and a zero‑padded index. Then one final request asks the server to join them.
The join is where S3’s own rules get in the way. Multipart upload is the right tool, but every part except the last must be at least 5 MB, and the chunks are 4 MB because they had to fit through the function. So the server reads chunks back in order and buffers them until it has enough for a valid part.
for (let index = 0; index < chunkKeys.length; index++) {
const response = await client.send(
new GetObjectCommand({ Bucket: connection.bucket, Key: chunkKeys[index] })
);
const bytes = await response.Body?.transformToByteArray();
if (!bytes) throw new Error(`Upload chunk ${index + 1} is missing`);
pendingBuffers.push(bytes);
pendingSize += bytes.length;
const isLastChunk = index === chunkKeys.length - 1;
if (!isLastChunk && pendingSize >= MINIMUM_MULTIPART_PART_SIZE) {
await uploadPendingAsPart();
}
}
A file small enough to never fill a part skips multipart and becomes a single PUT. If anything fails mid‑join, the multipart upload is aborted so the provider does not keep billing for orphaned parts, and the staged chunks are deleted in a finally block whether the join worked or not.
Keeping the staging area honest
A route that writes caller‑named keys into a bucket is an obvious hole, so the chunk routes do not take keys at all. The upload id must match a short lowercase pattern, the index is an integer in a fixed range, and the server builds the staging key from those two values. The completion route caps a file at 500 chunks and builds the same keys again. The caller can name the destination, which the role check already covers, and nothing else.
Why not presigned multipart from the browser
Presigning each multipart part and letting the browser upload them is the standard answer for huge files, and it was the first thing I considered. But it is cross‑origin too, so it needs exactly the CORS rule that a direct PUT needs. On a bucket where CORS works, a single presigned PUT already works. On a bucket where it does not, presigned multipart does not either. The fallback had to go through the function, so it had to fit the function.
What it costs
The fallback writes every byte twice: once as a chunk and once as a part, plus a read in between. The join must finish inside 300 seconds, which bounds the largest file the fallback can carry. And a staging prefix in the customer’s bucket is something to explain.
In exchange, there is one deploy, no upload server to run, and the common case, a bucket that accepts CORS, never touches the function at all. Most users never learn there is a second path, which is how a fallback should feel.