The early web ran on plain text. You typed a username or a comment into a form, hit submit, and a short string traveled to the server over HTTP. That simple request-response cycle defined the infrastructure. When people started wanting to share photos, documents, and videos, engineers had to figure out how to move raw binary data through a system built entirely for readable text.
If you try to drop a JPEG into a JSON object, you hit a fundamental wall. JSON is a text protocol. It expects valid Unicode characters, quotes, braces, and properly escaped strings. A binary file is just a long sequence of bytes, many of which have no printable representation. Jam those bytes into a JSON string and the parser breaks, escape sequences corrupt the payload, and the whole message becomes unreadable on the other side.
Base64 encoding emerged as the obvious workaround. It re-maps binary data into a limited set of sixty-four printable ASCII characters. Every three bytes of binary become four characters of text. The payload is now legal JSON, which means it will survive a trip through any standard API. But the cost is immediate. That re-encoding inflates the file size by roughly thirty-three percent. A three megabyte image becomes four megabytes on the wire. Both the client and the server burn extra CPU cycles translating the data back and forth. More importantly, many JSON server frameworks read the entire body into memory before parsing it. A handful of concurrent large uploads can swamp a modest server because each one is being held in RAM as an overweight text string before it even gets saved to disk. Base64 works in a pinch, but it was never designed to carry heavy files at production scale.
The better answer is multipart/form-data. This format treats a single HTTP request as a collection of separate parts, each divided by a unique boundary string. One part might hold a plain text field. The next part might hold a raw binary image, marked with its own Content-Type and Content-Disposition headers. The server reads the incoming stream sequentially, watching for the boundary markers, and hands each section off to the appropriate handler without ever needing to treat the entire payload as a single block of text.
In Node.js, this distinction is especially important. The express.json() middleware knows how to parse JSON bodies, but it does not handle file streams. To process multipart uploads, you need a streaming parser like Multer or Busboy. These tools attach to the raw request stream and read it chunk by chunk. Multer, for instance, lets you choose whether to write incoming files to a temporary folder on disk or hold smaller ones in memory. That configuration decision matters. If you keep everything in memory and your app suddenly receives several large files at once, your process can run out of heap space and crash. Writing to disk trades I/O for stability, but it introduces its own questions about cleanup and path security.
For a small application, saving files to a local folder like ./uploads feels natural and fast. The file lands on the same machine running your code, and serving it back is just a matter of pointing at the right path. This works right up until it does not.
The moment you place a load balancer in front of a second application server, local storage becomes a bug. A user uploads a profile picture. The load balancer routes the request to Server A, and the file is written to Server A's disk. Later, that user asks to view the image, but the load balancer sends the request to Server B. Server B checks its own filesystem and finds nothing. The file is effectively missing. You can implement sticky sessions to pin a user to the same machine, but that is a fragile fix. If Server A restarts, gets redeployed, or is replaced by an autoscaling instance, the data vanishes. In containerized environments, local disks are even more ephemeral. A Docker container’s filesystem is meant to be disposable. Treating it as permanent storage is a reliable way to lose user data.
The standard solution is to separate compute from storage. You keep your application servers stateless and send uploaded files to dedicated object storage like AWS S3 or Google Cloud Storage. These services are built for durability, geographic distribution, and massive concurrency. The application server handles the request, validates the metadata, and then hands the bytes off to infrastructure designed specifically to hold them.
Yet even this pattern creates a bottleneck if you implement it carelessly. Many teams start by having the browser upload the file to the backend, and then having the backend forward every single byte to object storage. If a user uploads a five-hundred megabyte video, your server becomes a middleman. It consumes bandwidth pulling the file in, then consumes more bandwidth pushing it out to S3. The connection stays open for the entire duration of the transfer. Slow uploads from users with poor network conditions can tie up server connections for minutes. Memory usage stays elevated if the server buffers the stream, and if you are running on metered hosting, you are paying twice for the same data transfer. Horizontal scaling does not solve this, because every additional server you add still gets stuck ferrying bytes it does not need to see.
Modern systems solve the problem by removing the backend from the data path entirely. Instead of accepting the file, the backend only accepts a request for permission to upload. The flow looks like this:
- The browser asks the backend to initiate an upload, usually sending only the filename, file type, and intended purpose.
- The backend authenticates the user, validates the request against business rules, and uses an SDK to generate a temporary presigned URL from the object storage provider.
- The backend returns that URL to the browser. The URL is scoped to a specific bucket and key, valid for a short window such as five or fifteen minutes, and signed with a token that grants only the precise permissions needed.
- The browser uploads the file directly to S3 or GCS using a standard PUT or POST. The bytes travel straight from the user’s device to
