FormData and File Uploads
Build multipart uploads with FormData, append files, inspect entries, and pair with fetch — progress and size limits included.
- javascript
- formdata
- upload
- fetch
FormData is the browser’s structured representation of form fields — including files — as multipart/form-data. You can build it from a <form> or append fields by hand, then hand it to fetch without manually crafting boundaries.
From a form
<form id="upload">
<input name="title" />
<input name="file" type="file" />
<button>Send</button>
</form>
const form = document.querySelector('#upload');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const body = new FormData(form);
const res = await fetch('/api/upload', {
method: 'POST',
body, // do NOT set Content-Type — browser sets boundary
});
if (!res.ok) throw new Error(String(res.status));
});
Setting Content-Type: multipart/form-data yourself without the boundary breaks the request. Leave headers alone for FormData bodies.
Manual append
const fd = new FormData();
fd.append('title', 'Report');
fd.append('file', fileInput.files[0], fileInput.files[0].name);
fd.append('tags', 'q1');
fd.append('tags', 'finance'); // multiple values same key
fd.set('title', 'Q1 Report'); // replace
fd.has('file'); // true
fd.get('title'); // string
fd.getAll('tags'); // ['q1', 'finance']
fd.delete('tags');
File / Blob values become file parts. Third argument to append sets the filename.
Iterate
for (const [key, value] of fd.entries()) {
console.log(key, value instanceof File ? value.name : value);
}
// also: fd.keys(), fd.values()
Multiple files
<input type="file" name="files" multiple />
const fd = new FormData();
for (const file of input.files) {
fd.append('files', file, file.name);
}
Server frameworks differ on whether they expect files or files[] — match your API.
Progress
fetch has no upload progress event. Use XMLHttpRequest when you need a bar:
function uploadWithProgress(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
};
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve(xhr.responseText)
: reject(new Error(String(xhr.status)));
xhr.onerror = () => reject(new Error('network'));
xhr.send(formData);
});
}
Validation before send
const MAX = 5 * 1024 * 1024; // 5MB
const file = input.files[0];
if (!file) throw new Error('no file');
if (file.size > MAX) throw new Error('too large');
if (!['image/png', 'image/jpeg'].includes(file.type)) {
throw new Error('type not allowed');
}
MIME file.type is advisory — servers must re-validate.
JSON + file
Some APIs want a JSON part plus a file:
fd.append(
'meta',
new Blob([JSON.stringify({ title: 'x' })], { type: 'application/json' }),
);
fd.append('file', file);
Or upload file first, then PATCH JSON with the returned id — often simpler.
Interview answer
“FormData models multipart form bodies. I construct it from a form or append fields/files, pass it as fetch body without setting Content-Type, and validate size/type client-side. Multiple values use repeated append keys. For upload progress I use XHR because fetch lacks upload events.”
Related
Credentials and CSRF
await fetch('/api/upload', {
method: 'POST',
body: fd,
credentials: 'same-origin',
headers: {
// Content-Type intentionally omitted
'X-CSRF-Token': csrfToken,
},
});
Multipart uploads still need the same auth story as JSON POSTs. For direct-to-S3/GCS uploads, the browser often PUTs a Blob to a pre-signed URL instead of FormData to your origin — smaller proxy load, different CORS setup. Validate magic bytes server-side; client file.type is easily spoofed.
Empty files and no-file cases
const file = input.files?.[0];
if (!file) {
// user cleared the input — don’t append empty file parts unless API expects it
} else if (file.size === 0) {
throw new Error('empty file');
}
Some backends treat a missing file field differently from an empty file part. Mirror existing form posts when reverse-engineering APIs. For resumable large uploads, FormData is usually the wrong abstraction — use chunked Blob.slice + dedicated upload sessions.
Further reading
Related guides
- AbortControllerCancel fetch and other async work with AbortController and AbortSignal — timeouts, race conditions, and cleanup when components unmount.
- Fetch API FundamentalsHow fetch really works — Response.ok, one-shot bodies, JSON errors, AbortController, credentials, and production footguns.
- Array find, some, every, includesShort-circuiting array predicates: find, findIndex, some, every, and includes — when to use each and common interview traps.
- Array map, filter, reducemap, filter, and reduce as the core transform toolkit — immutability, chaining costs, reduce patterns, and when a plain loop is clearer.
- Arrow Functions Deep DiveLexical this, no arguments object, no construct, concise bodies — when arrows help and when methods and generators need classic functions.