A customer chose some photos to add to their media library, waited, and got nothing back. No error. No message. The library looked exactly as it had before. They tried again, and once more, and then stopped.
The upload code had a size limit of fifty megabytes and a clear message for anything over it. The photos were around six megabytes. On paper, they should have gone straight through.
Whose limit runs first
The upload sent the file's bytes through a server action, which runs as a serverless function. The hosting platform caps the body of any request to a function at four and a half megabytes, and it enforces that cap before the request reaches the function. Anything larger gets a 413 response from the platform itself.
So my fifty-megabyte check was a promise nothing could keep. It sat inside code that never ran for exactly the files it had been written to catch. The message that would have explained the refusal could never be shown, because the request had already been turned away upstream.
The browser side made the silence complete. The upload was wrapped in a try with a finally and no catch. The rejection from the 413 escaped as an unhandled promise rejection. The finally stopped the spinner. The error banner, which only rendered from a catch, never appeared.
The platform's limit is documented. I had simply not connected it to this path, because the path had been written as if my function were the first thing a request would meet.
Take the bytes out of the function
The fix follows the pattern the chat's own file upload already used. The browser asks the server for a signed upload address, which is a small request well under any limit. The server checks the user, the workspace and the declared file, and only then issues the address. The browser sends the bytes directly to storage. The function never sees the file, so the platform's body cap is no longer in the path at all.
The maximum size is now one number shared by three layers: the browser's pre-check, the action that issues the signed address, and the storage bucket's own limit. That number equals the largest image the classifier, which reads each upload so Cleo can describe it, will fetch. An image that can be stored is always an image Cleo can still see.
I checked this against production storage rather than only in tests. Six megabytes, the size that had failed, now uploads. Fifteen uploads. Twenty-five is refused with an explanation.
What turned up around it
Changing an upload path is a good moment to read everything near it, and several smaller problems came out.
The upload ceiling had sat about a megabyte above the classifier's fetch limit, so a photo could upload successfully and then be invisible to Cleo for good. A test was meant to guard this, but its bound had been written as the constant plus the exact size of the gap, so in effect it compared a number with itself.
Files with an unusual extension, or none, uploaded every byte and were only then refused, because the stored name did not carry a standard extension.
Entries were titled from the storage key, which produced names made of a long timestamp followed by the original filename.
A raw upload defaults to no caching, so every uploaded image was downloaded again on every render. The SDK call it replaced had set an hour.
The error banner, the only place a failure was reported, fell below a comfortable contrast ratio and was not announced to screen readers.
And finishing a batch of twenty files revalidated the media page twenty-one times.
None of these was dramatic. Together they were the difference between an upload that works and an upload that feels as though it works.
The general shape
When a platform imposes a limit in front of your code, your own limit is only real if it is lower. Otherwise the check exists, passes review, and never runs. It is worth listing, for every entry point, what stands between the user and your first line of code, and what each of those layers will refuse without asking you.