Migrate to RunxBuild today!! Deploy More while Spending Less

Calculate your savings
unxBuild

AJAX Form File Upload: FormData, fetch, and a Real Server Handler

Sean

Platform Writer

Sep 23, 2026
9 min read

Uploading a file over AJAX without a page reload means building a FormData object from the file input, sending it with fetch, or with XMLHttpRequest when a progress bar matters, and parsing the resulting multipart request on the server with something built for it, such as multer in an Express app. The part most tutorials skip is what happens after the bytes arrive: enforcing size limits at the client, the server, and the reverse proxy in front of it, validating the file before trusting it, and putting the finished upload somewhere that survives a redeploy instead of the container’s own filesystem.

AJAX Form File Upload: FormData, fetch, and a Real Server Handler

This covers the whole path end to end: the HTML form, the client-side JavaScript using both fetch and XHR, a working Express handler with multer, the three places a size limit actually needs to live, and the storage decision that determines whether uploads are still there next week. Every code sample below is meant to be copied and adapted directly, not just read for the idea.

Table of contents

The HTML: a plain file input, no page reload

The form itself needs almost nothing special beyond enctype=multipart/form-data and a file input; the page-reload-free behavior comes entirely from JavaScript intercepting the submit event and calling preventDefault before the browser gets a chance to do its default full-page form submission. The enctype attribute is easy to forget and easy to miss in testing, because a plain form without it still submits, it just encodes the file input incorrectly, so the failure shows up as a missing or corrupted file on the server rather than an obvious client-side error.

<form id="upload-form" enctype="multipart/form-data">
  <label for="avatar">Choose a file</label>
  <input type="file" name="avatar" id="avatar" required />
  <button type="submit">Upload</button>
</form>
<p id="progress"></p>
<p id="result"></p>

Building the request with FormData and fetch

Passing the form element directly to the FormData constructor picks up every named field automatically, file input included, and encodes the whole thing as multipart/form-data without any manual encoding work. The one rule worth memorizing is to never set the Content-Type header manually on this request; the browser needs to generate it itself so it can include the multipart boundary string, and setting it yourself breaks that.

const form = document.querySelector('#upload-form');

form.addEventListener('submit', async (event) => {
  event.preventDefault();

  const formData = new FormData(form);

  try {
    const response = await fetch('/api/upload', {
      method: 'POST',
      body: formData,
    });

    if (!response.ok) {
      throw new Error('upload failed with status ' + response.status);
    }

    const result = await response.json();
    document.querySelector('#result').textContent = 'Uploaded: ' + result.filename;
  } catch (err) {
    document.querySelector('#result').textContent = 'Upload failed';
  }
});

XHR when you need a progress bar

fetch has no built-in event for upload progress, which surprises people the first time they go looking for one; the response can be streamed, but the request body cannot report progress through fetch alone. XMLHttpRequest still can, through its upload.onprogress event, which is why it remains the practical choice any time a visible progress bar matters, such as a large file on a slow connection.

A progress bar alone is not the whole UX. The submit button should disable the moment the request starts, so a second click during a slow upload cannot fire a duplicate request against the same file. The percentage from onprogress reflects bytes sent to the browser’s network stack, not bytes fully received and processed by the server, so treat it as a reasonable estimate of client-side progress rather than a guarantee the server has finished; the actual confirmation is still the response that arrives in onload. It is also worth wiring xhr.onerror and xhr.ontimeout alongside onload, since a network failure partway through a large upload should show the user a clear failed state rather than leave a progress bar frozen at some partial percentage with no explanation.

Cancellation matters for the same reason: a user who picked the wrong file, or who simply navigates away, should be able to stop an in-flight upload rather than let it keep consuming bandwidth in the background. With XMLHttpRequest, calling xhr.abort() on a cancel button’s click handler stops the request immediately and fires the onabort event instead of onload. The fetch-based version from the previous section supports the same idea through AbortController: create a controller, pass its signal into the fetch call’s options, and call controller.abort() from the cancel handler; fetch then rejects with an AbortError that the surrounding try/catch can distinguish from a genuine network failure and handle with its own message rather than a generic upload failed.

function uploadWithProgress(form) {
  const xhr = new XMLHttpRequest();
  const formData = new FormData(form);

  xhr.open('POST', '/api/upload');

  xhr.upload.addEventListener('progress', (event) => {
    if (event.lengthComputable) {
      const percent = Math.round((event.loaded / event.total) * 100);
      document.querySelector('#progress').textContent = percent + '%';
    }
  });

  xhr.onload = () => {
    if (xhr.status >= 200 && xhr.status < 300) {
      const result = JSON.parse(xhr.responseText);
      document.querySelector('#result').textContent = 'Uploaded: ' + result.filename;
    }
  };

  xhr.send(formData);
}

Parsing multipart data on the server with multer

On a Node and Express backend, multer is the standard way to parse an incoming multipart/form-data request, pulling the uploaded file out of the request and attaching it to req.file (or req.files for multiple), while leaving the other form fields on req.body as usual. The limits option is where server-side size enforcement actually lives, covered in more depth in the next section.

const express = require('express');
const multer = require('multer');

const app = express();

const upload = multer({
  dest: '/tmp/uploads',
  limits: { fileSize: 5 * 1024 * 1024 },
});

app.post('/api/upload', upload.single('avatar'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ error: 'no file received' });
  }

  res.json({ filename: req.file.filename, size: req.file.size });
});

app.listen(3000);

Size limits belong in three places, not one

A single size check anywhere in the stack is not enough, because each layer fails differently if it is missing its own check.

  • Client-side, checking file.size before sending. Fast feedback for the user, but not a security boundary; anyone can bypass client-side JavaScript entirely.
  • Server-side, multer’s limits.fileSize. The actual enforcement; a request over the limit is rejected here with a controllable error response your frontend can read.
  • The reverse proxy or platform in front of the app. Most proxies and platforms enforce their own request body size cap, and if a file is too large for that layer, it typically returns a bare 413 with no application-level JSON your frontend expects, which is worth testing deliberately rather than discovering in production.

Validating what actually came through

A file’s extension and its browser-reported Content-Type are both supplied by the client and both trivially spoofed, so neither is a safe way to decide what a file actually is. A more reliable check inspects the file’s real signature, the first few bytes, against an allowlist of expected types rather than trusting the filename or the claimed mimetype. It is also worth generating a new filename on the server rather than trusting the one the client sent, since an uploaded filename is user input and should be treated with the same caution as any other.

The signature check itself, often called a magic-bytes check, is a short, well-documented list once you have it: a JPEG starts with the three bytes FF D8 FF, a PNG starts with an eight-byte sequence beginning 89 50 4E 47, and a PDF starts with the ASCII text %PDF. Reading those first few bytes from the uploaded buffer and comparing them against the allowlist for your upload type catches a file that was renamed to look like an image but is not one, which a check on the filename’s extension alone cannot. In Node, a small library such as file-type does this signature lookup for you across a wide range of formats rather than hand-maintaining a byte table; either approach runs on the server, after multer has already saved or buffered the upload, as one more gate before the file is treated as trusted.

This matters most for anything that gets served back to other visitors later, such as an avatar or an attachment on a shared page. A file with a spoofed extension that slips past a naive check can end up served with the wrong Content-Type, or worse, executed as something it was never meant to be if it lands somewhere the web server treats as script-eligible. Rejecting anything outside an explicit allowlist, rather than trying to blocklist everything dangerous, is the safer default because the list of dangerous file types is longer than anyone remembers to maintain.

Where the uploaded file should live

A container’s own filesystem is not a safe place to keep an uploaded file long-term; a redeploy, a restart, or a scale-out event can wipe it, which is exactly the failure mode described in why uploaded images disappear after a deploy. The fix is to write uploads to persistent storage attached to the service rather than into the same directory as the application code, so the file survives independently of the app’s own lifecycle. The Node.js file system module is worth reading if the multer example above needs to write somewhere other than a temporary directory, and the RunxBuild dashboard is where that storage gets attached to a running service.

How this fits the rest of the stack

Getting the client-side upload right is the easy half; the part that actually determines whether uploads survive past next week is where they end up written and whether the size limit holds at every layer, not just the one you tested. A Node.js API deployed on RunxBuild can attach persistent storage directly to the service, so uploaded files stay put across redeploys; the RunxBuild hosting calculator covers what that costs alongside the service itself.

Useful related references:

FAQ

How do I upload a file with AJAX without refreshing the page?

Build a FormData object from the form, either manually with append or by passing the form element to the FormData constructor, then send it with fetch or XMLHttpRequest inside a submit event handler that calls preventDefault so the browser never performs its default full-page submission.

Why doesn’t fetch show upload progress?

The Fetch API does not expose an event for request body upload progress; it can stream a response but not report progress on the outgoing request. XMLHttpRequest’s upload.onprogress event still does this, which is why it remains the practical choice whenever a visible progress bar is needed.

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

Pass a limits option to multer, such as multer({ limits: { fileSize: 5 * 1024 * 1024 } }) for a 5MB cap. This is the actual server-side enforcement; client-side checks and any reverse proxy body-size limit are additional layers, not substitutes for this one.

Why did my uploaded files disappear after I redeployed?

Most likely they were written to the container’s own filesystem, which is typically wiped or replaced on redeploy, restart, or scale-out. The fix is writing uploads to persistent storage attached to the service instead of a directory alongside the application code.

Should I validate a file’s mimetype or its extension?

Neither alone is reliable, since both are supplied by the client and can be spoofed. A more trustworthy check inspects the file’s actual byte signature against an allowlist of expected types, rather than trusting the filename or the browser-reported Content-Type header.

#ajax form file upload#formdata fetch upload#xhr upload progress#multer file upload#multipart form data node