Blog

A better path for large Studio uploads

Large files were failing before Studio could make a useful decision about them. The culprit was not the image model. It was a generic request guard and one unnecessary copy in the middle.

The bug report sounded like an image problem: a high-resolution photo worked through the API but failed in Studio. In fact, the image never got close to the processor. A global 10 MB request guard rejected it at the website boundary, before the Studio route inspected the upload or forwarded a single byte.

That distinction matters. When a product says it supports a file size, the picker, browser checks, proxy, and production service all need to tell roughly the same story. Ours didn't. Worse, the proxy was doing extra work with every upload that did get through.

01 / A GENERIC RULE

The guard protected everything and explained nothing

The SvelteKit application applied one 10 MB body limit to every POST and PUT request. That was a defensible default for forms and account routes. An image upload is not an ordinary form, though, and high-resolution files routinely cross that line while still being perfectly valid inputs.

The route could not return a useful image-specific error because it never saw the request. The interface could advertise one limit while the outer server enforced another. Those contradictions are especially annoying because every layer is “working as designed.” The combined product is simply wrong.

10 MBthe old site-wide body guard
30 MBthe current public file limit
100 MPthe decoded-image safety ceiling

We made request limits configurable and gave Studio and Playground one shared public upload setting. The default is now 30 MB. That is not the only safety check; it is the check for compressed transfer size, which is just one dimension of image cost.

02 / THE EXPENSIVE MIDDLE

We were unpacking the upload just to pack it again

The old proxy called request.formData(), pulled out the uploaded File, created a new FormData object, and sent that rebuilt body upstream. In friendly pseudocode it looked harmless. In memory, the website process had to parse and hold the multipart request before it could start forwarding the same payload.

Old proxy path

  • Receive the entire multipart upload.
  • Parse the form into application objects.
  • Create a second multipart body.
  • Send the rebuilt body to the image API.

Current path

  • Check headers and the user boundary.
  • Forward the original request stream.
  • Stream the image response back.
  • Keep validation where it actually belongs.

A 30 MB limit would have been a questionable improvement if the proxy still created duplicate in-memory representations. Raising a number is not an architecture. Removing the parse-and-rebuild step gave the larger allowance a sane path through the application.

03 / THE SHORTER ROUTE

Forward the stream, keep the product checks

The Studio route now copies the incoming multipart Content-Type, adds the server-side API key, and passes request.body directly to the upstream fetch with streaming enabled. The upstream image response is returned as a stream too.

Bytes move; responsibility stays put

BrowserValidate type and the public size before upload.
WebsiteResolve the user, reserve usage, and forward the stream.
Image APIDecode, enforce pixel limits, process, and return bytes.

Streaming does not mean the proxy has no responsibilities. It still checks a known content length, requires multipart form data, resolves the Studio user, reserves free usage when necessary, and checks that a successful upstream response is actually an image. What changed is that the proxy stopped pretending it needed to own the payload.

The fastest copy of a large upload is the one the middle service never makes.

04 / HONEST LIMITS

Thirty megabytes and 100 megapixels protect different things

Compressed file size controls transfer time and immediate memory pressure. Decoded dimensions control the amount of pixel data and processing work hidden inside the file. A highly compressed image can be small on the wire and enormous after decode, which is why the production API separately rejects inputs above 100 megapixels.

413 at the edge

A known content length exceeds the public request boundary.

415 at the proxy

The request is not the multipart form the Studio route expects.

Decode errors upstream

The bytes arrived, but they do not form a usable supported image.

Pixel limit upstream

The compressed file is acceptable, but its decoded dimensions are not.

Studio and Playground now read the same upload-limit module, so picker text, drag-and-drop validation, and client-side messages use the same 30 MB default. The API remains the final authority. Each boundary can now explain the particular resource it is protecting instead of producing one mysterious “upload failed” halfway through.

The interaction is still synchronous: the browser waits for one processed image response. Large catalogs should use their own durable queue and bounded workers. For the person dropping a single high-resolution photo into Studio, though, the path is finally what it should have been all along—validate, stream, process, stream back.

The web workflow accepts JPEG, PNG, WebP, HEIC, and HEIF inputs up to the shared public limit.

Open Studio