Migrating XHR Upload Code to Fetch
Map every XHR behaviour your uploader relies on to its fetch equivalent before changing code — xhr.timeout to AbortSignal.timeout(), onerror to a rejected promise, xhr.status checks to res.ok, withCredentials to credentials: "include", abort() to an AbortController — keep XHR (wrapped in a promise) only where you need upload progress, and pin the behaviour with tests that run against both implementations.
Old upload code tends to be a single XHR function that grew for a decade: a timeout, a retry, a progress bar, a CSRF header, a workaround for a browser that no longer exists. Rewriting it with fetch is worthwhile — promises compose, AbortSignal combines timeouts with user cancellation, and the same code runs in service workers and Node — but the two APIs disagree in small ways that turn into production bugs: errors that no longer reject, credentials that are no longer sent, a Content-Type that is now wrong. This page belongs to modern fetch API for uploads in upload fundamentals and browser APIs. The progress question — the one thing fetch still lacks — is covered in fetch upload progress vs XMLHttpRequest.
When to use this approach
- You maintain an XHR-based uploader and want promises,
AbortSignal, and a codebase that uses one HTTP API. - You are moving upload logic into a service worker or a shared module used by Node, where XHR does not exist.
- You can test both the old and new paths against the same endpoint before switching traffic.
Prerequisites
- An inventory of what the current XHR code does: timeouts, retries, headers, credentials, response parsing, progress, abort.
AbortSignal.timeout()andAbortSignal.any()— available in all current browsers and Node 20+.- A test endpoint that can return slow responses, errors and redirects on demand.
The behaviour map
Implementation
A typical legacy function, then its fetch replacement with identical observable behaviour:
// BEFORE: the XHR uploader as it usually looks after years of patches.
export function uploadXhr(url: string, file: File, csrf: string): Promise<{ id: string }> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.timeout = 120_000;
xhr.withCredentials = true;
xhr.responseType = "json";
xhr.setRequestHeader("X-CSRF-Token", csrf);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response as { id: string });
else reject(new Error(`HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("network error"));
xhr.ontimeout = () => reject(new Error("timeout"));
const form = new FormData();
form.append("file", file, file.name);
xhr.send(form);
});
}
// AFTER: fetch with the same contract, plus caller-supplied cancellation.
export class UploadError extends Error {
constructor(message: string, readonly kind: "http" | "network" | "timeout" | "aborted", readonly status?: number) {
super(message);
this.name = "UploadError";
}
}
export async function uploadFetch(
url: string, file: File, csrf: string, opts: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<{ id: string }> {
const form = new FormData();
form.append("file", file, file.name);
const timeout = AbortSignal.timeout(opts.timeoutMs ?? 120_000);
const signal = opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout;
let res: Response;
try {
res = await fetch(url, {
method: "POST",
body: form, // no Content-Type header: fetch adds the boundary
credentials: "include", // was withCredentials = true
headers: { "X-CSRF-Token": csrf },
signal,
});
} catch (err) {
const name = (err as DOMException).name;
if (name === "TimeoutError") throw new UploadError("timeout", "timeout");
if (name === "AbortError") throw new UploadError("aborted", "aborted");
throw new UploadError("network error", "network");
}
// fetch resolves for 4xx/5xx — XHR code rejected on them, so we must too.
if (!res.ok) throw new UploadError(`HTTP ${res.status}`, "http", res.status);
const text = await res.text(); // tolerate empty 201/204 bodies
return (text ? JSON.parse(text) : { id: "" }) as { id: string };
}
Line-by-line on the differences that bite
if (!res.ok) throw. The most important line. XHR code usually rejected on non-2xx insideonload;fetchresolves for any HTTP response. Without this check, a 413 or a 500 flows into the success path and the UI shows “uploaded”.- Distinguishing
TimeoutErrorfromAbortError.AbortSignal.timeout()aborts with aTimeoutError; a user cancellation aborts withAbortError. Keeping them distinct preserves the old code’s separateontimeouthandling, which usually drives different retry behaviour — see aborting uploads with AbortController and timeouts. AbortSignal.any. Combines the timeout with a caller’s cancel signal, which XHR could only do with manual wiring.credentials: "include". The default issame-origin. A cross-origin upload endpoint that relied onwithCredentialssilently loses its cookies after migration and returns 401. The server must also answer withAccess-Control-Allow-Credentials: trueand a specific origin, as before.- No
Content-TypeforFormData. XHR code sometimes set it explicitly (and a browser quirk tolerated it). Withfetch, setting it removes the boundary and breaks the body. res.text()then parse.xhr.responseType = "json"yieldsnullfor an empty body;res.json()throwsSyntaxError: Unexpected end of JSON input. Parsing text first handles both.
Run both implementations against the same tests
// Vitest (browser mode) — the endpoint /test/scenario/:name simulates each case.
import { describe, it, expect } from "vitest";
import { uploadXhr, uploadFetch, UploadError } from "./upload.ts";
const file = new File(["hello"], "a.txt", { type: "text/plain" });
const impls = { xhr: uploadXhr, fetch: uploadFetch } as const;
for (const [name, upload] of Object.entries(impls)) {
describe(name, () => {
it("resolves with the id on 201", async () => {
await expect(upload("/test/scenario/ok", file, "t")).resolves.toMatchObject({ id: expect.any(String) });
});
it("rejects on 413", async () => {
await expect(upload("/test/scenario/413", file, "t")).rejects.toThrow(/413/);
});
it("rejects on 500", async () => {
await expect(upload("/test/scenario/500", file, "t")).rejects.toThrow(/500/);
});
});
}
it("fetch reports timeouts distinctly", async () => {
await expect(uploadFetch("/test/scenario/slow", file, "t", { timeoutMs: 500 }))
.rejects.toSatisfy((e: UploadError) => e.kind === "timeout");
});
Configuration gotchas
Uploads succeed in tests, fail in production with 401. The production upload endpoint is on another subdomain and relied on withCredentials. Add credentials: "include" and confirm the server’s CORS response allows credentials.
TypeError: Failed to fetch where XHR used to work. A CORS response the browser previously accepted for a simple XHR now fails, typically because the fetch sends a header (like X-Requested-With) or a method that triggers a preflight the server does not answer. Compare the request headers of both in DevTools.
SyntaxError: Unexpected end of JSON input. The server returns 201 or 204 with no body; res.json() throws where xhr.response was null. Read text first.
Progress bar stopped working. It depended on xhr.upload.onprogress. Keep the XHR path for progress-critical uploads, or move to chunked uploads where progress comes from chunk completion.
What to keep on XMLHttpRequest
Migration does not have to be total. Three situations justify leaving a code path on XHR, wrapped in the same promise interface as everything else.
Byte-accurate progress for single-request uploads. If the product shows a smooth percentage for one-shot uploads in every browser, XHR is still the only API that provides it. Keep a small uploadWithProgress helper on XHR and use fetch for everything else — API calls, chunked uploads, retries of failed chunks.
Very old embedded browsers. Some kiosk systems, smart TVs and in-app webviews ship engines old enough to lack AbortSignal.timeout() or AbortSignal.any(). If your analytics show real traffic from them, feature-detect and keep XHR as the fallback, rather than polyfilling half of the modern API surface.
Code that is about to be replaced anyway. If the single-request uploader is on its way out in favour of chunked or direct-to-storage uploads, migrating it to fetch first is wasted effort. Move straight to the new design, which uses fetch from the start — see multipart vs single-PUT for files under 100MB for when that switch makes sense.
The goal is one interface, not one implementation. As long as every caller uses the same promise-returning function with the same error kinds, which API sits underneath is a detail you can change per path.
Migration order that limits risk
During the gradual switch, compare upload error rates by kind — HTTP, network, timeout — between the two implementations. A rise in “network” errors on the fetch side usually means a CORS or credentials difference, not a real network problem.
Verification
Beyond the automated table, check the wire in DevTools for one upload from each implementation: the request headers should match (apart from the boundary string), the cookies sent should match, and the Content-Type should be multipart/form-data; boundary=… in both.
# Server-side: both implementations should produce identical request shapes.
grep 'POST /api/upload' access.log | awk '{print $NF, $(NF-1)}' | sort | uniq -c
Frequently Asked Questions
Should I migrate at all if XHR works?
Migrate when you need what fetch offers — signals, streams, service worker or Node compatibility, a consistent codebase — not for its own sake. Wrapping XHR in a promise already gives most of the ergonomic benefit.
Can fetch retry automatically like some XHR libraries did?
No, neither API retries on its own. Libraries that appeared to were implementing retries around XHR. Port that logic explicitly, with the idempotency safeguards in retrying fetch uploads with idempotency keys.
What about keepalive: true for uploads that should outlive the page?
keepalive caps the body at 64 KB in total across in-flight keepalive requests, so it cannot carry files. It is for small beacons like analytics. For uploads that outlive the page, see background and offline uploads.