ESC

Type to search the knowledge base.

FormData and File Uploads

Build multipart uploads with FormData, append files, inspect entries, and pair with fetch — progress and size limits included.

intermediate3 min read
  • 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.”

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