Multipart Form Data
How files travel over HTTP
When a browser submits a form with a file input, it does not send JSON. It sends multipart/form-data: a format that splits the body into parts, each with its own headers and content.
A multipart body looks like this:
POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="description"
My vacation photo
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg
<binary data>
------WebKitFormBoundary7MA4YWxkTrZu0gW-- Each part is separated by a boundary string. Text fields (like description) are sent as plain text. File fields include the filename and MIME type, followed by the raw binary content.
Why not JSON?
JSON is text-based. Binary files (images, PDFs, videos) would need to be base64-encoded, increasing the size by ~33%. Multipart sends raw bytes, which is more efficient.
JSON also cannot represent multiple fields with different content types in one payload. Multipart can: a text description and a binary file in the same request.
Parsing with busboy
busboy is a streaming multipart parser. It processes the request body chunk by chunk, emitting events for each field and file. It never loads the entire file into memory.
// src/upload.ts
import Busboy from "busboy";
import { Readable } from "node:stream";
interface ParsedUpload {
fields: Record<string, string>;
file?: {
fieldName: string;
filename: string;
mimeType: string;
stream: Readable;
};
}
export function parseMultipart(request: Request): Promise<ParsedUpload> {
return new Promise(async (resolve, reject) => {
const contentType = request.headers.get("content-type");
if (!contentType?.includes("multipart/form-data")) {
reject(new Error("Expected multipart/form-data"));
return;
}
const busboy = Busboy({ headers: { "content-type": contentType } });
const fields: Record<string, string> = {};
let fileResult: ParsedUpload["file"];
// "field" fires for each text field in the form (like "description")
busboy.on("field", (name, value) => {
fields[name] = value;
});
// "file" fires for each file field. stream is a Readable of the file's bytes.
busboy.on("file", (fieldName, stream, info) => {
fileResult = {
fieldName,
filename: info.filename,
mimeType: info.mimeType,
stream,
};
});
// "close" fires when the entire multipart body has been parsed
busboy.on("close", () => {
resolve({ fields, file: fileResult });
});
// "error" fires if the multipart body is malformed
busboy.on("error", (err) => {
reject(err);
});
// Pipe the request body into busboy
const body = request.body;
if (!body) {
reject(new Error("No request body"));
return;
}
const reader = body.getReader();
const nodeStream = new Readable({
async read() {
const { done, value } = await reader.read();
if (done) {
this.push(null);
} else {
this.push(Buffer.from(value));
}
},
});
nodeStream.pipe(busboy);
});
} The key: busboy processes the multipart body as a stream. The file data arrives as a Readable stream — we can pipe it directly to disk without ever holding the full file in memory.
Using it in a route
route.post("/upload", {
resolve: async (c) => {
const { fields, file } = await parseMultipart(c.request);
if (!file) {
return Response.json({ error: "No file uploaded" }, { status: 400 });
}
console.log("Fields:", fields);
console.log("File:", file.filename, file.mimeType);
// Next lesson: validate and save the file
// For now, consume the stream (discard)
for await (const chunk of file.stream) {
// process chunk...
}
return Response.json({ message: "File received", filename: file.filename });
},
}); Try it
curl -X POST http://localhost:3000/upload \
-F "description=My photo" \
-F "[email protected]"
# { "message": "File received", "filename": "photo.jpg" } The -F flag tells curl to send multipart form data. @photo.jpg reads the file from disk.
Exercises
Exercise 1: Implement the parseMultipart function and the upload route. Upload a file with curl and verify the filename and MIME type are printed.
Exercise 2: Upload a form with multiple text fields (like description and tags). Verify they appear in the fields object.
Exercise 3: What happens if you send a JSON body to the upload endpoint? (The multipart parser rejects it because the Content-Type is wrong.)
Why does busboy process files as a stream instead of loading them into memory?