初期のウェブはプレーンテキストで動作していました。フォームにユーザー名やコメントを入力して送信ボタンを押すと、短い文字列がHTTP経由でサーバーに送られました。その単純なリクエスト・レスポンスのサイクルがインフラを定義していました。人々が写真、ドキュメント、動画を共有したいと考えるようになると、エンジニアは、読み取り可能なテキスト専用に構築されたシステムを通じて、生のバイナリデータをどのように移動させるかを考え出さなければなりませんでした。

JSONオブジェクトにJPEGを入れようとすると、根本的な壁に突き当たります。JSONはテキストプロトコルです。有効なUnicode文字、引用符、中括弧、および適切にエスケープされた文字列を想定しています。バイナリファイルは単なる長いバイト列であり、その多くは表示可能な表現を持ちません。これらのバイトをJSON文字列に詰め込むと、パーサーが壊れ、エスケープシーケンスがペイロードを破損させ、メッセージ全体が受信側で読み取れなくなります。

Base64エンコーディングが明らかな回避策として登場しました。これはバイナリデータを、64種類の表示可能なASCII文字の限定されたセットに再マッピングします。バイナリの3バイトごとに、テキストの4文字になります。ペイロードはこれで合法的なJSONとなり、標準的なAPIを経由しても問題なく伝送できます。しかし、その代償はすぐに現れます。この再エンコーディングにより、ファイルサイズが約33%膨張します。3MBの画像は、通信路上では4MBになります。クライアントとサーバーの両方が、データの相互変換のために余分なCPUサイクルを消費します。さらに重要なことに、多くのJSONサーバーフレームワークは、パースする前にボディ全体をメモリに読み込みます。少数の大規模なアップロードが同時に発生するだけで、控えめなスペックのサーバーでもパンクする可能性があります。なぜなら、各アップロードがディスクに保存される前に、肥大化したテキスト文字列としてRAM内に保持されるからです。Base64は緊急時には役立ちますが、本番環境のスケールで重いファイルを運ぶようには設計されていません。

より優れた答えは multipart/form-data です。このフォーマットは、単一のHTTPリクエストを、一意の境界(boundary)文字列によって分割された、個別のパーツの集合として扱います。あるパーツはプレーンテキストのフィールドを持ち、次のパーツは独自の Content-TypeContent-Disposition ヘッダーを持つ生のバイナリ画像を持つかもしれません。サーバーは、境界マーカーを監視しながら、入ってくるストリームを順次読み込み、ペイロード全体を単一のテキストブロックとして扱う必要なく、各セクションを適切なハンドラーに渡していきます。

Node.jsにおいて、この区別は特に重要です。express.json() ミドルウェアはJSONボディのパース方法を知っていますが、ファイルストリームは扱いません。マルチパートのアップロードを処理するには、MulterやBusboyのようなストリーミングパーサーが必要です。これらのツールは生の要求ストリームにアタッチし、チャンクごとに読み取ります。例えばMulterを使用すると、受信したファイルをディスク上のテンポラリフォルダに書き込むか、小さなファイルであればメモリに保持するかを選択できます。この設定の判断は重要です。すべてをメモリに保持している状態で、アプリに突然複数の大きなファイルが届くと、プロセスがヒープ領域を使い果たしてクラッシュする可能性があります。ディスクへの書き込みは、安定性のためにI/Oを犠牲にするものですが、クリーンアップやパスのセキュリティに関する独自の課題も生じます。

小規模なアプリケーションであれば、ファイルを ./uploads のようなローカルフォルダに保存するのは自然で高速に感じられます。ファイルはコードを実行しているのと同じマシンに保存され、それを配信するのは正しいパスを指定するだけです。これは、問題が起こる直前まではうまく機能します。

2台目のアプリケーションサーバーの前にロードバランサーを配置した瞬間、ローカルストレージはバグへと変わります。ユーザーがプロフィール写真をアップロードします。ロードバランサーはリクエストをサーバーAにルーティングし、ファイルはサーバーAのディスクに書き込まれます。後でそのユーザーが画像を表示しようとすると、ロードバランサーはリクエストをサーバーBに送ります。サーバーBは自身のファイルシステムを確認しますが、何も見つかりません。ファイルは実質的に消失しています。ユーザーを同じマシンに固定するためにスティッキーセッションを実装することもできますが、それは脆弱な修正策です。サーバーAが再起動したり、再デプロイされたり、オートスケーリングのインスタンスに置き換わったりすると、データは消えてしまいます。コンテナ化された環境では、ローカルディスクはさらに一時的なものです。Dockerコンテナのファイルシステムは使い捨てであることが前提です。それを永続的なストレージとして扱うことは、ユーザーデータを失う確実な方法です。

標準的な解決策は、計算(compute)とストレージを分離することです。アプリケーションサーバーをステートレスに保ち、アップロードされたファイルは AWS S3 や Google Cloud Storage のような専用のオブジェクトストレージに送信します。これらのサービスは、耐久性、地理的分散、および大規模な同時実行のために構築されています。アプリケーションサーバーはリクエストを処理してメタデータを検証し、その後、データを保持するために特別に設計されたインフラストラクチャにバイト列を渡します。

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