Engineering
From 10MB to 500MB: Sending Cereby's Uploads Straight to the Bucket
Why the server stopped carrying the file, and how it kept the final say anyway
Picture a student dropping a 14MB slide deck into Cereby. The upload fails with a generic error and nothing to act on: no "file too large", no limit, no hint. Behind it, the platform had refused the request with a 413 (payload too large) before our handler ever ran, and our error screen could not read the platform's response.
Cereby is a study workspace. Students bring lecture slides, textbook PDFs, recorded audio and screen recordings so the app can parse them and use them in chat, quizzes and focus mode. If the materials cannot get in, nothing else matters, and for a long stretch anything past about 10MB could not. This post is about how we moved file uploads off our server's request path in April: the browser sent the bytes straight to storage, and the server kept the final say on what was allowed in.
The limit was the body parser
Uploads went through a Next.js API route as a multipart form. Here is the 14MB deck on that path:
The limit was not storage. Next.js caps an API route's body well below what its serverActions.bodySizeLimit setting suggests, because that setting applies to Server Actions, not to route handlers. Even under the cap, the whole file sat in the function's memory before storage saw a byte, so a 12MB upload meant a 12MB buffer on the heap. And because the 413 happened above our code, we could not shape it into a useful message.
The memory problem changed the question. The server had no business touching the bytes at all. Its job was to know who the user was, check the metadata and write a database row. Carrying the file through the request was something we had inherited from the obvious shape of an upload form, not something we needed.
The browser carries the bytes, the server keeps the books
We split the upload into two phases. The browser sent the bytes straight to Supabase Storage, our file store, where files live in buckets. Then it told the server, in a few hundred bytes of JSON, what it had written and where:
Once the bytes went around the request, the body limit stopped applying. The browser named the path itself (a timestamp, a short random suffix and the cleaned-up filename) to save a round trip asking the server for one. That was harmless: the server could reject any path that failed its checks.
When this post first went up, the ceiling was 50MB, what Supabase's free tier allowed per object. The next day we moved to Supabase Pro, which raises that limit by orders of magnitude, and lifting the in-app cap to 500MB was a one-line edit. The design did not change; it was what made the jump that small.
The server never took the client's word
The pointer said how big the file was and what type it was. The server checked both against the bucket before anything else:
The comparison is what closed a quota bypass, because the declared size was what the quota check read. Without it, a client could under-report its bytes and slip past its tier's limit. A synthetic case:
Pointer declared size 2 MB, type PDF (synthetic)
Bucket the object at that path: 240 MB
Check declared ≠ actual → rejected, object deleted
A request with no signed-in user got a 401, and nothing was written or deleted. After that, every upload passed the same gates: a 500MB size cap and an allowlist of about 16 types (PDF, DOCX, PPTX, XLSX, TXT, MD, images, MP3, WAV, MP4), each failing with a 400, and a tier-aware check of the bytes a student had left, failing with a 403. Every failure deleted the object before returning its error. That best-effort delete was the cost of uploading first and checking second.
We kept the old multipart path alive for callers sending small files. The route chose its reader by the request's Content-Type, and both readers produced the same internal shape, so validation, quota, the database row and the parse job were written once. The legacy reader was still bound by the 10MB ceiling, but rolling every small-file caller over was not worth a coordinated push.
Uploaded but not visible
A two-phase upload can fail halfway. The common case was the first phase succeeding and the second never firing, usually because a tab closed or the network blipped between the two: the object existed, no row did, and the student saw nothing in their materials. We handled it in two places:
Neither was enough alone. The browser's cleanup missed closed tabs, and a reaper on its own would have left orphans sitting for a sweep cycle.
Before and after
| Through the API route | Straight to the bucket | |
|---|---|---|
| Largest file | About 10MB, set by the body parser | 500MB, set by us (50MB before Supabase Pro) |
| Request body to our server | The whole file | A few hundred bytes of JSON |
| Server memory per upload | One full copy of the file | None |
| A file that is too big | The platform's 413, unreadable | Our own 400, with a reason |
| Size and type checked against | The parsed body | The object in the bucket |
| Orphaned objects | Impossible, one phase | Possible, cleaned up twice over |
Errors in the upload modal said what had gone wrong (size, type or quota), because the failure happened inside our code instead of above it. The route stopped allocating multi-megabyte buffers per request. And the modal could show real progress in the first phase, a request straight to storage that reports it, which the API-route path never could.
What this taught us
Find the actual layer. "Next.js has a 10MB limit" was true but underspecified: the body parser was the layer, and Server Action limits and storage limits were separate things. Routing around the right layer was the unlock.
The client's metadata is a claim, even when the client uploaded the bytes. Re-reading size and type from storage cost almost nothing and closed a real quota bypass.
In a two-phase flow, orphans are part of the architecture, not an afterthought. The browser's cleanup and the reaper each covered what the other missed.
And parsing had to stay separate from uploading. If a 500MB PDF had to finish OCR before the upload returned, we would have traded one cliff for another; parsing in the background, with a lazy backfill on first read, is what made the larger ceiling feel responsive.
What's next
Resumable uploads. Supabase Storage supports tus, a protocol for resuming an interrupted upload, and we did not use it yet. At 500MB the case was much stronger than at 50MB: a network blip 80% of the way through a half-gigabyte upload is a real failure, and "start over" is a poor answer for a student on cellular. And hashing files in the browser would let a re-upload of the same file dedupe at the bucket instead of producing a second object.