Uploading files and multipart forms
Build a multipart body from files and fields, report progress honestly, validate before the bytes leave the browser, and know what resumable means.
Building the body
const fd = new FormData();
// values from a form, including file inputs
for (const [name, value] of new FormData(formEl)) fd.append(name, value);
// add a file that was never in a form
fd.append('avatar', file, 'avatar.png'); // third argument sets the filename
fd.append('alt', 'Team photo');
fd.append('tags', 'one'); // repeated names become an array
fd.append('tags', 'two');
// never set Content-Type by hand: the browser must add the boundary
await fetch('/api/upload', { method: 'POST', body: fd });
// sending JSON instead of multipart, for comparison
await fetch('/api/upload-meta', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ alt: 'Team photo', size: file.size })
});- Setting
Content-Type: multipart/form-datayourself removes the boundary parameter and the server cannot parse the body. Let the browser set the header. FormDatavalues are strings or blobs. A number becomes text, so the server must parse it, and a boolean becomes the string 'true'.- A
Filefrom an input is aBlobwith a name. You can append any blob, which is how canvas exports and generated PDFs are uploaded. - A whole form can be passed to the constructor:
new FormData(formEl)collects every named control, including hidden inputs.
Progress reporting
Upload progress is the one capability fetch still does not expose. If a progress bar matters, XMLHttpRequest remains the straightforward tool, or you can send the file in slices and count the slices that have completed.
function upload(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = event => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.upload.onload = () => onProgress(100); // bytes sent, response still pending
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300)
? resolve(JSON.parse(xhr.responseText))
: reject(new Error('HTTP ' + xhr.status));
xhr.onerror = () => reject(new Error('network'));
xhr.ontimeout = () => reject(new Error('timeout'));
xhr.timeout = 120000;
const fd = new FormData();
fd.append('file', file);
xhr.open('POST', '/api/upload');
xhr.send(fd);
});
}lengthComputableis false when the total size is unknown, which happens with a streaming body. Guard for it or your bar jumps to NaN.- Upload progress reaching 100 percent means the bytes left the browser. The server may still be writing them to disk, so keep the UI in a pending state until the response arrives.
- A cancelled upload cannot be resumed. The request is gone, and the bytes already accepted by the server are unaccounted for.
Checks, drag and drop, and resumable uploads
const MAX = 20 * 1024 * 1024;
const OK_TYPES = ['image/png', 'image/jpeg', 'application/pdf'];
function check(file) {
if (file.size > MAX) return 'File is larger than 20 MB';
if (!OK_TYPES.includes(file.type)) return 'Unsupported type: ' + (file.type || 'unknown');
return null;
}
dropzone.addEventListener('drop', event => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
for (const f of files) {
const problem = check(f);
if (problem) { showError(f.name + ': ' + problem); continue; }
upload(f, percent => setProgress(f.name, percent)).catch(showError);
}
});
// resumable: slice the file and upload numbered chunks
async function chunked(file, chunkSize = 5 * 1024 * 1024, uploadId) {
for (let offset = 0; offset < file.size; offset += chunkSize) {
const slice = file.slice(offset, Math.min(offset + chunkSize, file.size));
await fetch('/api/upload/' + uploadId + '?offset=' + offset, {
method: 'PUT',
body: slice
});
}
}⚠️
Client-side size and type checks are user experience, not security. A determined client can send anything, and file.type is guessed from the extension on most systems. Re-check the size, the real content type and the file signature on the server before storing or serving the file.
FAQ
Why does my upload fail with a boundary error?
The
Content-Type header was set manually, so the multipart boundary is missing. Delete the header and let the browser generate it from the FormData body.Can I show upload progress with fetch?
Not for the request body, because fetch exposes no upload progress events. Use
xhr.upload.onprogress, or split the file into chunks and send them sequentially so progress is the count of completed chunks.Related
Streaming and long-lived connections Building an API client layer
Last refreshed 2026-09-18.