Upload Files Using AJAX: Modern Techniques 2026
Learn to upload files using AJAX with modern techniques. Covers vanilla JS (XHR/Fetch), progress, error handling, and serverless setup. Guide updated for 2026.
You’ve probably hit this exact moment. The form looks done, the file input works, and then the first real test upload refreshes the page, clears half the user’s data, and leaves you guessing whether the file arrived.
That’s why teams use AJAX for uploads. Not because it’s trendy, but because file transfer is one of the fastest ways to make a form feel broken if you handle it like a classic full-page submit. A production-ready upload flow needs the client request, the server response, progress feedback, validation, and failure states to work together.
Most examples stop at “pick a file and POST it.” That’s not enough when you need to upload files using Ajax on a real site, especially a static site or a frontend-heavy app where the backend piece is the actual blocker.
Table of Contents
- Why Modern File Uploads Use AJAX
- The Core Client-Side Mechanics with Vanilla JavaScript
- Building a Robust User Experience
- Server-Side Expectations and Common Hurdles
- The No-Code Backend Solution with FormBackend
- Conclusion Your Path to Flawless File Uploads
Why Modern File Uploads Use AJAX
A traditional upload flow is clumsy in ways users notice immediately. They select a file, click submit, the page reloads, and if anything fails they often lose context, status, or unrelated form input. That’s a bad fit for profile editors, job application forms, support portals, and any workflow where the upload is only one part of a bigger interaction.
AJAX changes that. The browser sends the file in the background, and the page stays active while the transfer runs. That became practical in mainstream browsers once the HTML5-era File API and XMLHttpRequest improvements made asynchronous uploads possible without a full page reload, shifting upload UX from blocking navigation to event-driven interaction, as described in this overview of Ajax JavaScript file upload behavior.

That technical shift changed expectations. Users now assume they can keep typing, switch tabs in a form, or at least see clear feedback while a file uploads. If your page locks them into a full refresh, the interface feels older than the rest of your app.
What AJAX means in this context
For uploads, AJAX usually means this:
- The form submit is intercepted: JavaScript prevents the default navigation.
- The file is packaged into
FormData: That keeps the request compatible with file uploads. - The browser sends an asynchronous
POST: The UI stays on the same page. - The server returns a response: Usually success, validation failure, or an error payload your UI can display.
Practical rule: A good upload flow should feel like part of the page, not a detour away from it.
That’s the main reason to learn how to upload files using Ajax well. It isn’t about replacing a form submit with clever JavaScript. It’s about keeping state intact, showing progress, and handling real failure conditions without punishing the user.
The Core Client-Side Mechanics with Vanilla JavaScript
The client-side foundation is simple once you strip away demo fluff. You need a form, a file input, a submit handler, FormData, and an asynchronous request.
One implementation pattern shows up across many guides because it works: call preventDefault() on submit, append the selected file or files to FormData, open the request asynchronously, and send it so the page doesn’t refresh. That exact pattern is shown repeatedly in this walkthrough on uploading files with Ajax.

Start with the HTML form
Keep the markup boring. That’s good.
<form id="upload-form"> <label for="file">Choose a file</label> <input id="file" name="file" type="file" multiple /> <button type="submit">Upload</button> </form> <p id="status" aria-live="polite"></p> <progress id="progress" value="0" max="100" hidden></progress>
A few notes matter here:
- Use
name="file"or the field name your backend expects. Mismatched field names are a common failure. - Use
multipleonly if the server supports multiple files. The browser won’t save you from a backend mismatch. - Keep a status element in the DOM. You’ll need somewhere to show validation errors and completion messages.
XMLHttpRequest with FormData
If you need upload progress, XMLHttpRequest is still the straightforward choice.
const form = document.getElementById('upload-form'); const fileInput = document.getElementById('file'); const statusEl = document.getElementById('status'); const progressEl = document.getElementById('progress'); form.addEventListener('submit', (event) => { event.preventDefault(); const files = fileInput.files; if (!files.length) { statusEl.textContent = 'Select at least one file.'; return; } const formData = new FormData(); for (const file of files) { formData.append('file', file, file.name); } const xhr = new XMLHttpRequest(); xhr.open('POST', '/upload', true); progressEl.hidden = false; progressEl.value = 0; statusEl.textContent = 'Uploading...'; xhr.upload.onprogress = (event) => { if (!event.lengthComputable) return; const percent = Math.round((event.loaded / event.total) * 100); progressEl.value = percent; statusEl.textContent = `Uploading... ${percent}%`; }; xhr.onload = () => { const contentType = xhr.getResponseHeader('content-type') || ''; const isJson = contentType.includes('application/json'); if (xhr.status >= 200 && xhr.status < 300) { const data = isJson ? JSON.parse(xhr.responseText) : null; statusEl.textContent = data?.message || 'Upload complete.'; progressEl.value = 100; return; } let message = 'Upload failed.'; if (isJson) { try { const data = JSON.parse(xhr.responseText); if (data?.error) message = data.error; } catch (err) {} } statusEl.textContent = message; }; xhr.onerror = () => { statusEl.textContent = 'Network error. Check your connection and try again.'; }; xhr.send(formData); });
This pattern is old, but not outdated. It gives you direct hooks into progress, completion, and transport-level failure.
XHR is more verbose, but that extra verbosity buys you dependable upload progress handling without extra gymnastics.
Fetch with FormData
If you don’t need built-in upload progress events, fetch gives you cleaner code.
const form = document.getElementById('upload-form'); const fileInput = document.getElementById('file'); const statusEl = document.getElementById('status'); form.addEventListener('submit', async (event) => { event.preventDefault(); const files = fileInput.files; if (!files.length) { statusEl.textContent = 'Select at least one file.'; return; } const formData = new FormData(); for (const file of files) { formData.append('file', file, file.name); } statusEl.textContent = 'Uploading...'; try { const response = await fetch('/upload', { method: 'POST', body: formData }); const contentType = response.headers.get('content-type') || ''; const isJson = contentType.includes('application/json'); const data = isJson ? await response.json() : null; if (!response.ok) { throw new Error(data?.error || 'Upload failed.'); } statusEl.textContent = data?.message || 'Upload complete.'; } catch (error) { statusEl.textContent = error.message || 'Something went wrong.'; } });
This version is easier to read, especially when your page already uses async and await. It also maps well to component-based apps.
For a quick reference on wiring an asynchronous form submission to a hosted endpoint, the FormBackend AJAX usage guide shows the same general direction from the request side.
Which one should you choose
Here’s the practical comparison:
| Method | Good fit | Main drawback |
|---|---|---|
| XMLHttpRequest | Progress bars, fine-grained upload events, mature event model | More boilerplate |
| Fetch API | Cleaner request code, easier async flow, simpler common cases | Upload progress is not the obvious default path |
Use XHR when the upload experience itself is part of the product. Use fetch when the upload is simpler and a progress bar isn’t critical.
A lot of tutorials frame this as old versus new. That’s the wrong decision model. Pick based on feedback requirements, not fashion.
Building a Robust User Experience
A file upload can succeed technically and still feel unreliable. Users judge the flow by what they can see: whether the button responds, whether progress moves, whether errors make sense, and whether the interface tells them what to fix before they try again.

A common client pattern combines FormData, XMLHttpRequest.open('POST', ...), and xhr.send(formData). Examples around that pattern also started adding client-side checks early, including constraints like JPG-only uploads under 300,000 bytes, which shows how quickly validation became part of a reliable upload flow, as shown in this HTML5 Ajax file upload guide.
Progress feedback that users can trust
A progress bar doesn’t need to be fancy. It needs to be honest.
xhr.upload.onprogress = (event) => { if (!event.lengthComputable) return; const percent = Math.round((event.loaded / event.total) * 100); progressEl.hidden = false; progressEl.value = percent; statusEl.textContent = `Uploading... ${percent}%`; };
Use progress only when you can calculate it. If the browser can’t compute length, show an indeterminate state like “Uploading…” instead of a fake percentage.
Good progress UX also means disabling repeated submits:
submitButton.disabled = true; xhr.onloadend = () => { submitButton.disabled = false; };
Without that, users double-click, trigger duplicate requests, and you end up debugging a “backend issue” that started in the UI.
Validation before the request leaves the browser
Client-side validation is not security. It is still worth doing because it saves time and reduces obvious bad requests.
Check type and size before upload:
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']; const maxSize = 300000; for (const file of fileInput.files) { if (!allowedTypes.includes(file.type)) { statusEl.textContent = `${file.name} is not an allowed file type.`; return; } if (file.size > maxSize) { statusEl.textContent = `${file.name} is too large.`; return; } }
Keep validation messages specific. “Upload failed” is not useful if the actual problem is “PDF accepted, but video files are not.”
The browser should catch obvious mistakes early. The server should still verify everything again.
That second sentence matters. Never assume frontend checks are enough.
Here’s a short walkthrough if you want to see a file upload interface in motion:
Responses that make failures understandable
Most broken upload flows have one of two problems:
- They treat every non-success as the same error
- They assume the server always returns perfect JSON
Handle both.
xhr.onload = () => { const type = xhr.getResponseHeader('content-type') || ''; const isJson = type.includes('application/json'); if (xhr.status >= 200 && xhr.status < 300) { statusEl.textContent = 'Upload complete.'; return; } if (isJson) { try { const data = JSON.parse(xhr.responseText); statusEl.textContent = data.error || 'Upload failed.'; return; } catch (err) {} } statusEl.textContent = `Upload failed with status ${xhr.status}.`; };
Success messages should also be grounded. If the server stores the file and returns a name or URL, show that. If moderation or post-processing happens later, don’t claim the file is fully available yet.
Server-Side Expectations and Common Hurdles
The biggest misconception in upload tutorials is that the backend is a small detail. It isn’t. The browser side is usually the easy part. The hard part is whether the endpoint accepts the request shape you’re sending, validates the file safely, stores it correctly, and answers with a response your UI can use.
That gap shows up in current coverage too. Many tutorials explain FormData, XHR, and basic POST handling, but stop short of the practical production concerns around secure validation, size limits, and error handling, as discussed in this note on Ajax file upload gaps in production guidance.
What your frontend expects from the server
Your JavaScript doesn’t need to know every backend detail, but it does need a few guarantees:
- The endpoint accepts
multipart/form-dataand reads the file field you send. - The server validates type and size based on its own rules.
- The response is predictable so your UI can show success or failure cleanly.
- Failures return useful error messages instead of generic HTML error pages.
If you’re building a custom handler, a practical reference for the backend side is this guide to implementing file uploads with PHP. It helps when you need to align frontend field names, upload directories, and validation logic with what the server expects.
Why CORS fails so often
CORS is where many frontend uploads die before the file ever reaches your app logic. If your page is on one origin and your upload endpoint is on another, the server has to explicitly allow that cross-origin request.
From the frontend point of view, the symptom is annoying. The code looks right, the network panel is confusing, and the browser blocks the request. That doesn’t usually mean your FormData is wrong. It usually means the server response is missing the headers the browser requires for cross-origin access.
If the browser says CORS, stop tweaking the JavaScript first. Verify the server is willing to accept the origin you’re sending from.
Security is not an optional add-on
A production upload endpoint needs more than “save file and return OK.”
At minimum, expect the server to handle:
- Type verification: Don’t trust the filename extension alone.
- Size enforcement: The backend must reject oversized payloads even if the client already checked.
- Filename handling: Raw user-supplied names can create collisions or unsafe paths.
- Error responses: Return something your frontend can parse and display.
If you don’t control the server, ask for the contract up front. What field name should the file use? What formats are accepted? What does a failed response look like? Those questions save hours.
For teams that don’t want to build the server layer themselves, the FormBackend file upload feature details show the kind of backend behavior a form endpoint should expose to a frontend developer.
The No-Code Backend Solution with FormBackend
If your bottleneck is the backend, the fastest route is often to stop treating uploads as a custom server project. For many frontend teams, what’s needed is simple: accept a file from an HTML form, process the submission, and return a response without setting up and maintaining upload infrastructure.

FormBackend is one way to do that. It provides a form endpoint you can point your frontend to, including file submissions, so static sites and frontend-heavy apps can submit multipart/form-data without building a dedicated upload handler first.
That matters most in three common setups.
Static HTML
A plain HTML page can send files asynchronously with almost no structural change. The important part is still the form encoding and the endpoint.
<form id="upload-form" enctype="multipart/form-data"> <input type="file" name="file" /> <button type="submit">Send</button> </form> <p id="status"></p>
const form = document.getElementById('upload-form'); const statusEl = document.getElementById('status'); form.addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(form); try { const response = await fetch('YOUR_FORM_ENDPOINT', { method: 'POST', body: formData }); if (!response.ok) { throw new Error('Upload failed.'); } statusEl.textContent = 'Upload complete.'; } catch (error) { statusEl.textContent = error.message; } });
This is useful when you’re working on a landing page, agency site, or documentation portal that has no custom backend at all.
A working reference helps here. This contact form with file uploads example shows how the form structure maps to a hosted endpoint.
React or Next.js style component
In component-based apps, the main difference is state handling. The network pattern stays the same.
import { useState } from 'react'; export default function UploadForm() { const [status, setStatus] = useState(''); async function handleSubmit(event) { event.preventDefault(); setStatus('Uploading...'); const formData = new FormData(event.currentTarget); try { const response = await fetch('YOUR_FORM_ENDPOINT', { method: 'POST', body: formData }); if (!response.ok) { throw new Error('Upload failed.'); } setStatus('Upload complete.'); } catch (error) { setStatus(error.message); } } return ( <form onSubmit={handleSubmit} encType="multipart/form-data"> <input type="file" name="file" /> <button type="submit">Upload</button> <p>{status}</p> </form> ); }
This approach works well when your app is mostly frontend logic and you’d rather spend your time on UI state than request parsing, storage, and validation plumbing.
Webflow custom code
Webflow-style builds often run into the same problem as static HTML. The frontend is ready, but there’s no custom upload endpoint behind it.
In a custom embed or page footer script, the integration looks familiar:
<form id="upload-form" enctype="multipart/form-data"> <input type="file" name="file" required> <button type="submit">Upload file</button> </form> <p id="status"></p> <script> const form = document.getElementById('upload-form'); const statusEl = document.getElementById('status'); form.addEventListener('submit', async (event) => { event.preventDefault(); try { const response = await fetch('YOUR_FORM_ENDPOINT', { method: 'POST', body: new FormData(form) }); if (!response.ok) { throw new Error('Upload failed.'); } statusEl.textContent = 'Upload complete.'; } catch (error) { statusEl.textContent = error.message; } }); </script>
Hosted form backends are most useful when the frontend is already solved and the server work is the part you don’t want to own.
That doesn’t remove the need for validation, clear messages, or testing real failure states. It just removes the need to build the receiving endpoint from scratch.
Conclusion Your Path to Flawless File Uploads
When you upload files using Ajax, the frontend code is only one piece of the job. You need a request method that fits the UI, a response model that supports clear messaging, and a backend that accepts multipart/form-data without surprises.
The practical choices are straightforward:
- Use
XMLHttpRequestwhen progress events matter. - Use
fetchwhen the flow is simpler and cleaner async code matters more. - Validate early in the browser so users get instant feedback.
- Validate again on the server because client checks are never enough.
- Treat CORS and response shape as first-class concerns instead of last-minute debugging details.
Most demos show the happy path. Real uploads live in the unhappy paths too: wrong file type, oversized payload, flaky connection, duplicate clicks, backend mismatch, and cross-origin failures.
That’s why the backend decision matters so much. You can build and maintain the entire upload pipeline yourself, or you can use a hosted form endpoint when your project doesn’t need custom upload infrastructure. The right answer depends on how much of the stack you want to own.
A solid upload flow doesn’t feel complicated to the user. It feels boring, fast, and dependable. That’s exactly what you want.
Add a form backend to your site in minutes
Connect any HTML form to FormBackend and start collecting submissions — no backend code required.
Start free