Multipart/Form-Data Encoding: File Uploads from Browser to Server

Quick answer

πŸ’‘multipart/form-data sends each field as a separate part with its own headers, separated by a unique boundary string. Let the browser or FormData API generate the boundary automatically β€” never set Content-Type manually when using FormData. On the server, use multer (Node.js/Express) or busboy to parse multipart bodies. File fields have a filename and Content-Type; text fields do not.

Error symptoms

  • βœ•Multer req.file is undefined even though the client is sending a file
  • βœ•fetch() with FormData appending files works in browser but fails with 'boundary not found' on server
  • βœ•Content-Type header manually set to 'multipart/form-data' breaks the boundary parameter
  • βœ•File upload appears as empty string in req.body instead of req.file
  • βœ•413 Payload Too Large error before the file reaches the multer middleware
  • βœ•Uploaded file content is corrupted β€” binary data mangled β€” when processed as UTF-8 string

Common causes

  • β€’Manually setting Content-Type: multipart/form-data without the required boundary parameter
  • β€’Using express.json() or express.urlencoded() middleware on a multipart route, which consumes the stream without parsing files
  • β€’Setting the wrong field name in multer's upload.single('fieldname') that does not match the FormData append key
  • β€’Not handling file size limits: no maxFileSize option set, leading to unexpected 413 errors or memory exhaustion
  • β€’Using fetch with a manually constructed multipart body and incorrect CRLF line endings
  • β€’Attempting to read req.body before multer middleware has run, getting undefined file fields

When it happens

  • β€’When building a file upload endpoint in Express for the first time
  • β€’When switching from base64-encoded file upload in JSON body to proper multipart upload
  • β€’When adding file upload to an existing API that uses express.json() globally
  • β€’When uploading files from a mobile app or desktop client (not a browser) to a Node.js server
  • β€’When implementing drag-and-drop file uploads with progress tracking in a React application

Examples and fixes

Let the browser set Content-Type automatically when using FormData. Configure multer to handle the multipart stream on the server.

File upload with FormData and multer β€” correct boundary handling

❌ Wrong

// Client: manually setting Content-Type breaks the boundary
const formData = new FormData();
formData.append('avatar', fileInput.files[0]);
await fetch('/api/upload', {
  method: 'POST',
  headers: {
    'Content-Type': 'multipart/form-data' // WRONG: missing boundary param
  },
  body: formData
});
// Server receives: 'boundary not found in multipart' error
// multer cannot parse without the boundary string

βœ… Fixed

// Client: let the browser set Content-Type with boundary automatically
const formData = new FormData();
formData.append('avatar', fileInput.files[0]);
await fetch('/api/upload', {
  method: 'POST',
  // Do NOT set Content-Type β€” browser sets it with boundary:
  // Content-Type: multipart/form-data; boundary=----FormBoundaryXyz...
  body: formData
});
// Server with multer:
const multer = require('multer');
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 } // 5 MB limit
});
app.post('/api/upload', upload.single('avatar'), (req, res) => {
  // req.file: { fieldname, originalname, buffer, mimetype, size }
  console.log(req.file.originalname, req.file.size);
  res.json({ uploaded: true });
});

The multipart/form-data Content-Type header must include a boundary parameter that identifies where each part begins and ends in the request body. When you manually set Content-Type: multipart/form-data without the boundary parameter, the server cannot parse the multipart stream and throws an error. The browser generates a unique boundary string automatically when you assign a FormData object to the fetch body β€” the value looks like ----WebKitFormBoundaryABC123. Manually setting Content-Type overrides this automatic header and removes the boundary parameter. The fix is to simply not set the Content-Type header when the body is a FormData object. Multer on the server needs to know the field name passed to upload.single() β€” it must match the key used in formData.append().

Handle multiple file uploads with individual and total size limits, and handle multer errors for oversized files.

Uploading multiple files with multer and handling size limits

❌ Wrong

// No size limits β€” server will run out of memory with large files
const upload = multer({ storage: multer.memoryStorage() });
app.post('/api/photos', upload.array('photos'), (req, res) => {
  // req.files is an array of file objects
  // But with no limits, a 1 GB upload exhausts server memory
  res.json({ count: req.files.length });
});
// No error handling for MulterError
// 'LIMIT_FILE_SIZE' errors are swallowed silently

βœ… Fixed

const multer = require('multer');
const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 10 * 1024 * 1024, // 10 MB per file
    files: 5,                   // max 5 files per request
    fields: 10                  // max 10 non-file fields
  },
  fileFilter: (req, file, cb) => {
    const allowed = ['image/jpeg', 'image/png', 'image/webp'];
    if (allowed.includes(file.mimetype)) cb(null, true);
    else cb(new Error('Invalid file type'), false);
  }
});
app.post('/api/photos', (req, res, next) => {
  upload.array('photos', 5)(req, res, (err) => {
    if (err instanceof multer.MulterError) {
      return res.status(413).json({ error: err.message, code: err.code });
    } else if (err) {
      return res.status(400).json({ error: err.message });
    }
    res.json({ count: req.files.length });
  });
});

Without size limits, a malicious client can upload arbitrarily large files and exhaust server memory when using multer.memoryStorage(). The fileSize limit (bytes) causes multer to throw a MulterError with code 'LIMIT_FILE_SIZE' when exceeded. The files limit prevents too many files in a single request. The fileFilter callback allows rejecting files based on MIME type β€” note that MIME type is provided by the client and can be spoofed, so also validate the actual file magic bytes for security-sensitive applications. Multer errors are not automatically forwarded to Express error handlers β€” you must handle them in the callback by checking instanceof multer.MulterError.

How multipart/form-data encoding works

multipart/form-data is an HTTP encoding format defined in RFC 7578 for submitting forms that include files. Unlike application/x-www-form-urlencoded (which encodes all form values as key=value&key2=value2), multipart/form-data separates each field with a unique boundary string. This design allows files to be transmitted as raw binary without the 3x size overhead of URL encoding and without the 33% overhead of base64 encoding.

The boundary string is a random sequence of characters that does not appear in any of the form values or file contents. The Content-Type header for the request must include this boundary: Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123. Each part of the body starts with '--' followed by the boundary, then the part headers (Content-Disposition, Content-Type for binary parts), a blank line, and the field value or file bytes. The final boundary ends with '--' appended after the boundary string to signal the end of the multipart body.

Text fields in multipart/form-data have a Content-Disposition header of form-data; name="fieldName" and no Content-Type header (implied text/plain). File fields add filename="originalName.jpg" to the Content-Disposition and include a Content-Type header matching the file's MIME type. These differences are how parsers like multer distinguish between req.body (text fields) and req.file/req.files (file fields).

The browser's FormData API generates the boundary string automatically and sets the correct Content-Type header when the FormData object is used as the fetch() or XMLHttpRequest body. Manually constructing a multipart body is error-prone because: (1) the boundary must be unique enough to not appear in binary file content, (2) line endings must be CRLF (\r\n), not just LF (\n), and (3) the header-body separator is a blank CRLF line. Libraries like form-data (npm) handle this correctly for Node.js API clients.

Diagnosing multipart upload problems

When multer's req.file is undefined, the first check is whether the field name in upload.single('fieldname') exactly matches the key used in formData.append('fieldname', file). They are case-sensitive. The second check is whether the request actually has the correct Content-Type header with the boundary parameter. In browser devtools, go to Network, select the upload request, and look at the Request Headers β€” you should see Content-Type: multipart/form-data; boundary=....

If the Content-Type header shows multipart/form-data without a boundary= parameter, the boundary was stripped. This is caused by manually setting the Content-Type header, which removes the automatically generated boundary. The fix is to delete the explicit Content-Type header and let the browser or form-data library set it automatically.

When express.json() is applied globally before multer in the middleware stack, it may consume the request stream for multipart requests, leaving an empty or already-consumed stream for multer. Express.json() and express.urlencoded() should be applied only to routes that need them, not globally before all routes. Alternatively, use multer before express.json() for upload routes, or configure express.json() to skip requests with Content-Type multipart/form-data.

For '413 Payload Too Large' errors from nginx (not from multer), check your nginx client_max_body_size setting. The default is 1 MB. If you want to accept files larger than 1 MB, set client_max_body_size 50M; in the nginx location block for upload routes. The 413 from nginx is returned before the request reaches your Node.js server, so multer never sees the request. Verify which layer is returning 413 by checking the response headers β€” nginx 413 responses include Server: nginx.

Correct multipart upload implementation with multer

For browser-based file uploads, use the FormData API. Create a new FormData(), append the file with formData.append('fieldName', fileInput.files[0]), and pass the FormData as the fetch body without setting Content-Type. The browser automatically generates a unique boundary and sets the complete Content-Type header. For multiple files, use formData.append('photos', file) multiple times with the same field name, or use formData.append('photo1', file1), formData.append('photo2', file2) for distinct field names.

On the server, multer provides three storage engines. multer.memoryStorage() stores the file in req.file.buffer as a Buffer β€” convenient for small files and immediate processing but risky for large files that exhaust memory. multer.diskStorage({destination, filename}) saves files to disk β€” better for large files but requires cleanup. For cloud storage (AWS S3, Google Cloud Storage), use multer-s3 or multer-storage-cloudinary, which stream directly to cloud storage without buffering on your server.

For non-browser HTTP clients (Node.js API client, curl, Postman, mobile apps), use the form-data npm package. Create a new FormData instance (from the form-data package), append fields and files with .append(), and pass it as the request body with the headers from form.getHeaders() which includes the correct Content-Type with boundary. In curl: curl -F 'avatar=@/path/to/file.jpg' http://localhost:3000/api/upload β€” the -F flag handles multipart encoding and boundary generation automatically.

For validating file types securely, do not rely solely on the MIME type from the request β€” clients can set it to any value. Read the first few bytes of the file buffer and check the magic bytes. JPEG starts with FF D8 FF, PNG starts with 89 50 4E 47, PDF starts with 25 50 44 46. The file-type npm package provides magic byte detection for over 60 file formats: const type = await fileTypeFromBuffer(req.file.buffer). Validate both the claimed MIME type and the detected type from magic bytes.

Boundary conflicts, large files, and base64 in multipart

Boundary string collisions are theoretically possible but extremely unlikely with properly generated boundaries. The browser generates boundaries using a prefix like '----WebKitFormBoundary' followed by 16 random characters, giving approximately 2^96 possible values. If the boundary string happens to appear in the binary file content, the multipart parser will incorrectly split the file at that position. This is why boundaries must be randomly generated, not fixed strings like '--boundary--'.

For very large file uploads (hundreds of MB or several GB), streaming to disk or cloud storage is essential. Multer.memoryStorage() buffers the entire file in Node.js heap memory. A 100 MB upload adds 100 MB to your process's memory. For a server with 512 MB of available memory handling 10 concurrent uploads, this is immediately fatal. Use multer.diskStorage() with a temp directory and move files to their final destination after validation, or stream directly to S3 using multer-s3.

Base64-encoded files in multipart bodies are an anti-pattern but sometimes seen in mobile app codebases. Instead of sending the file bytes directly, some clients encode the file as base64 and send it as a text field. This adds 33% overhead and defeats the purpose of multipart encoding. On the server, such fields appear in req.body (not req.file) as a base64 string. Decode with Buffer.from(req.body.fileBase64, 'base64') to get the original bytes. The correct approach is to send the raw file bytes as a file field, not as a base64 text field.

Progress tracking for large uploads requires either the XMLHttpRequest API (which supports progress events) or a client-side chunk upload implementation. The fetch() API does not expose upload progress in browsers as of 2026. For progress tracking with fetch, use the ReadableStream request body with a custom stream that tracks bytes written. For simpler implementation, use XMLHttpRequest: xhr.upload.addEventListener('progress', e => setProgress(e.loaded / e.total * 100)).

Common multipart upload mistakes in production

Not removing files after processing is a common disk exhaustion bug. When using multer.diskStorage(), uploaded files are saved to the destination directory and remain there until your code deletes them. If a validation step rejects the file and returns an error without deleting the uploaded file, the temp directory gradually fills. Use fs.unlink(req.file.path) in error handlers and after successful processing. Set up a cron job to clean old files from the upload temp directory as a safety net.

Trusting the originalname from multer without sanitization is a path traversal vulnerability. multer passes req.file.originalname as-is from the client. An attacker can set the filename to '../../etc/passwd' or any other path. Never use originalname directly as a filesystem path. Generate a new filename with a UUID: const safeName = randomUUID() + path.extname(req.file.originalname). Validate the extension against an allowlist before using it.

Not setting a file size limit in multer exposes the server to denial-of-service attacks. Without a limit, an attacker can send a request claiming to be a 10 MB file and actually stream 100 GB of data. Multer will keep accepting data until the disk or memory is exhausted. Always set limits.fileSize to a reasonable maximum for your use case. Combine this with an nginx client_max_body_size setting as a first line of defense.

Parsing multipart form data with a JSON body parser is a common misconfiguration. If you have app.use(express.json()) applied globally in Express, it runs on every request including multipart uploads. For multipart requests, express.json() reads the stream (because it tries to parse it) and either fails silently or consumes the request body before multer can read it. The fix is to apply body parsing middleware selectively: either use router-level middleware, or skip body parsing when Content-Type is multipart/form-data.

Multipart file upload best practices for production

Set explicit limits for everything multer controls: fileSize (maximum bytes per file), files (maximum number of files), fields (maximum number of text fields), fieldSize (maximum text field value size in bytes). These limits prevent both accidental large uploads and intentional denial-of-service attacks. Document these limits in your API reference so clients know what to expect.

For files that need to be served back to users, never store uploaded files with their original names in a public directory. This creates two vulnerabilities: (1) path traversal if the filename contains ../, (2) stored XSS if the filename contains HTML and it is displayed unencoded, and (3) file overwriting if two users upload files with the same name. Generate a UUID-based filename, store the original name in your database for display, and serve files via a route that looks up the UUID and streams the file.

For compliance (GDPR, HIPAA, SOC 2), encrypt files at rest in your storage layer. S3 server-side encryption (SSE-S3 or SSE-KMS) is easy to enable and encrypts all uploaded objects transparently. For highly sensitive files, encrypt client-side before upload using SubtleCrypto (AES-256-GCM) and store the encryption key separately. Log all file uploads with timestamp, user ID, filename, size, and IP address for audit trail requirements.

Test your multipart implementation with: zero-byte files (should upload successfully or be rejected with a clear message), files at exactly the size limit and one byte over (tests boundary conditions), files with Unicode filenames ('照片.jpg', 'Ρ„ΠΎΡ‚ΠΎ.jpg'), files with path characters in the name ('../etc/passwd'), files whose extension does not match their magic bytes (JPEG with .png extension), and concurrent uploads from the same user. These test cases cover the most common production failures.

Quick fix checklist

  • βœ“Do not manually set Content-Type when using FormData β€” let the browser set it with the boundary
  • βœ“Match the field name in upload.single('fieldname') exactly to the FormData.append() key
  • βœ“Set multer limits: fileSize, files, and fields to prevent denial-of-service
  • βœ“Use multer.diskStorage() or multer-s3 for large files β€” not memoryStorage()
  • βœ“Delete uploaded files after processing, including on error paths
  • βœ“Sanitize uploaded filenames β€” never use originalname directly as a filesystem path
  • βœ“Validate file types using magic bytes (file-type package) not just MIME type from client
  • βœ“Check nginx client_max_body_size if 413 errors come before reaching your Node.js server

Related guides

Frequently asked questions

Why is multer req.file undefined?

The most common cause is a field name mismatch: upload.single('avatar') only populates req.file when the FormData field is also named 'avatar'. Check that formData.append('avatar', file) uses the same name. Also verify the Content-Type header includes the boundary parameter β€” if you manually set Content-Type: multipart/form-data without a boundary, the request cannot be parsed. Check browser devtools Network tab to confirm the Content-Type header.

Why should I not manually set Content-Type for FormData?

The Content-Type header for multipart/form-data must include a boundary parameter that identifies where each part begins and ends. The browser generates this boundary automatically when you use a FormData object as the fetch body. If you manually set Content-Type: multipart/form-data, you override the auto-generated header and lose the boundary parameter. The server receives the request without knowing where parts are separated and cannot parse it.

What is the difference between req.body and req.file in multer?

In a multipart request processed by multer, req.body contains text fields (FormData.append('fieldName', 'stringValue')), and req.file (or req.files) contains file fields (FormData.append('fieldName', fileObject)). The distinction comes from whether the FormData field is a string or a File/Blob object. Text fields appear in req.body; file objects with metadata (originalname, buffer, size, mimetype) appear in req.file.

How do I limit file upload size in Express with multer?

Set the limits.fileSize option in the multer() call: multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } }) limits uploads to 5 MB per file. When exceeded, multer throws a MulterError with code 'LIMIT_FILE_SIZE'. Handle this in the callback: if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: 'File too large' }). Also set nginx client_max_body_size in your proxy configuration.

Should I use base64 or multipart for file uploads?

Use multipart/form-data for file uploads. It sends files as raw binary with no encoding overhead. Base64 in a JSON body adds 33% size overhead, increases payload parsing time, and requires additional memory to hold the base64 string before decoding. The only reasonable case for base64 file upload is when the API requires JSON-only bodies (some strict APIs) or when the file is very small (under 1 KB) and JSON simplicity is preferred.

How do I upload files in Node.js to another API?

Use the form-data npm package. Create const form = new FormData(), append files with form.append('file', fs.createReadStream('/path/to/file'), {filename: 'file.jpg', contentType: 'image/jpeg'}), and pass the form to axios or fetch with the headers from form.getHeaders(). The form-data package generates the boundary automatically. Do not use the browser's built-in FormData in Node.js code β€” they are different implementations.

How do I track upload progress with JavaScript?

The fetch() API does not support upload progress events in browsers as of 2026. Use XMLHttpRequest instead: const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', e => { if (e.lengthComputable) setProgress(e.loaded / e.total * 100); }); xhr.open('POST', '/api/upload'); xhr.send(formData). For React, wrap the XHR in a useCallback hook and update state in the progress event. Alternatively, use the axios library which wraps XHR and supports onUploadProgress.

What happens if the boundary string appears in the file content?

If the boundary string appears in the file binary content, the multipart parser incorrectly splits the file at that position, corrupting the upload. This is why boundaries must be randomly generated with sufficient length. The browser generates boundaries like '----WebKitFormBoundaryXXXXXXXX' with 16 random characters. This gives roughly 2^96 possible values β€” the probability of collision with file content is negligible in practice. Never use a fixed hardcoded boundary string.

All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.