Het vroege web draaide op platte tekst. Je typte een gebruikersnaam of een reactie in een formulier, drukte op verzenden, en een korte reeks tekens reisde via HTTP naar de server. Die eenvoudige request-response-cyclus bepaalde de infrastructuur. Toen mensen foto's, documenten en video's wilden delen, moesten ingenieurs uitzoeken hoe ze ruwe binaire gegevens door een systeem konden verplaatsen dat volledig was gebouwd voor leesbare tekst.
Als je een JPEG in een JSON-object probeert te plaatsen, loop je tegen een fundamentele muur aan. JSON is een tekstprotocol. Het verwacht geldige Unicode-tekens, aanhalingstekens, accolades en correct geëscapte strings. Een binair bestand is slechts een lange reeks bytes, waarvan veel geen printbare weergave hebben. Als je die bytes in een JSON-string propt, gaat de parser kapot, corrumperen escape-sequenties de payload en wordt het hele bericht onleesbaar aan de andere kant.
Base64-encoding kwam naar voren als de voor de hand liggende workaround. Het mapt binaire gegevens opnieuw naar een beperkte set van vierenzestig printbare ASCII-tekens. Elke drie bytes aan binaire gegevens worden vier tekens tekst. De payload is nu geldige JSON, wat betekent dat deze een reis door elke standaard-API zal overleven. Maar de prijs is direct voelbaar. Die hercodering blaast de bestandsgrootte met ongeveer drieëndertig procent op. Een afbeelding van drie megabyte wordt vier megabyte over de lijn. Zowel de client als de server verbruiken extra CPU-cycli om de gegevens heen en weer te vertalen. Belangrijker nog: veel JSON-serverframeworks lezen de volledige body in het geheugen voordat ze deze parsen. Een handvol gelijktijdige grote uploads kan een bescheiden server overbelasten, omdat elke upload in het RAM-geheugen wordt vastgehouden als een te zware tekststring voordat deze zelfs maar naar de schijf wordt weggeschreven. Base64 werkt in noodsituaties, maar het is nooit ontworpen om zware bestanden op productieschaal te verwerken.
Het betere antwoord is multipart/form-data. Dit formaat behandelt een enkele HTTP-request als een verzameling afzonderlijke onderdelen, elk gescheiden door een unieke boundary-string. Eén onderdeel kan een tekstveld bevatten. Het volgende onderdeel kan een ruwe binaire afbeelding bevatten, gemarkeerd met zijn eigen Content-Type en Content-Disposition headers. De server leest de binnenkomende stream sequentieel, houdt de boundary-markers in de gaten en geeft elk gedeelte door aan de juiste handler, zonder dat de gehele payload ooit als een enkel tekstblok behandeld hoeft te worden.
In Node.js is dit onderscheid bijzonder belangrijk. De express.json() middleware weet hoe hij JSON-bodies moet parsen, maar hij kan geen file streams afhandelen. Om multipart-uploads te verwerken, heb je een streaming parser nodig zoals Multer of Busboy. Deze tools koppelen zich aan de ruwe request-stream en lezen deze stukje bij beetje. Multer laat je bijvoorbeeld kiezen of je binnenkomende bestanden naar een tijdelijke map op de schijf schrijft of kleinere bestanden in het geheugen houdt. Die configuratiebeslissing is cruciaal. Als je alles in het geheugen houdt en je app plotseling verschillende grote bestanden tegelijk ontvangt, kan je proces door een tekort aan heap-ruimte crashen. Schrijven naar de schijf ruilt I/O in voor stabiliteit, maar het brengt eigen vragen met zich mee over opschoning en padbeveiliging.
Voor een kleine applicatie voelt het opslaan van bestanden in een lokale map zoals ./uploads natuurlijk en snel aan. Het bestand komt terecht op dezelfde machine waarop je code draait, en het terugserveren is slechts een kwestie van naar het juiste pad wijzen. Dit werkt uitstekend, totdat het niet meer werkt.
Op het moment dat je een load balancer voor een tweede applicatieserver plaatst, wordt lokale opslag een bug. Een gebruiker uploadt een profielfoto. De load balancer stuurt de request naar Server A, en het bestand wordt geschreven naar de schijf van Server A. Later wil die gebruiker de afbeelding bekijken, maar de load balancer stuurt de request naar Server B. Server B controleert zijn eigen bestandssysteem en vindt niets. Het bestand is effectief verdwenen. Je kunt sticky sessions implementeren om een gebruiker aan dezelfde machine te koppelen, maar dat is een kwetsbare oplossing. Als Server A herstart, opnieuw wordt uitgerold of wordt vervangen door een autoscaling-instantie, verdwijnt de data. In gecontaineriseerde omgevingen zijn lokale schijven zelfs nog vluchtiger. Het bestandssysteem van een Docker-container is bedoeld om wegwerpbaar te zijn. Het behandelen ervan als permanente opslag is een betrouwbare manier om gebruikersgegevens te verliezen.
De standaardoplossing is om compute te scheiden van opslag. Je houdt je applicatieservers stateless en stuurt geüploade bestanden naar dedicated object storage zoals AWS S3 of Google Cloud Storage. Deze services zijn gebouwd voor duurzaamheid, geografische distributie en massale gelijktijdigheid. De applicatieserver handelt de request af, valideert de metadata en geeft de bytes vervolgens door aan infrastructuur die specifiek is ontworpen om ze te bewaren.
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
