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

  1. An inventory of what the current XHR code does: timeouts, retries, headers, credentials, response parsing, progress, abort.
  2. AbortSignal.timeout() and AbortSignal.any() — available in all current browsers and Node 20+.
  3. A test endpoint that can return slow responses, errors and redirects on demand.

The behaviour map

XMLHttpRequest features mapped to fetch equivalents Each row maps an XHR feature to fetch. xhr.timeout maps to AbortSignal.timeout. onerror maps to a rejected promise, but HTTP errors do not reject. withCredentials maps to credentials include. abort maps to AbortController. responseType json maps to res.json. upload.onprogress has no fetch equivalent. XHR → fetch, row by row XMLHttpRequest fetch trap xhr.timeout = 60000 signal: AbortSignal.timeout(60000) TimeoutError name onerror (network) promise rejects TypeError 4xx/5xx resolve! withCredentials = true credentials: "include" default is same-origin xhr.abort() controller.abort() AbortError name responseType = "json" await res.json() throws on empty body upload.onprogress no equivalent keep XHR or chunk The HTTP-error row causes most migration bugs: fetch treats a 500 as a successful exchange.
Most rows are mechanical; the error-handling row changes semantics and needs a deliberate check.

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 inside onload; fetch resolves for any HTTP response. Without this check, a 413 or a 500 flows into the success path and the UI shows “uploaded”.
  • Distinguishing TimeoutError from AbortError. AbortSignal.timeout() aborts with a TimeoutError; a user cancellation aborts with AbortError. Keeping them distinct preserves the old code’s separate ontimeout handling, 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 is same-origin. A cross-origin upload endpoint that relied on withCredentials silently loses its cookies after migration and returns 401. The server must also answer with Access-Control-Allow-Credentials: true and a specific origin, as before.
  • No Content-Type for FormData. XHR code sometimes set it explicitly (and a browser quirk tolerated it). With fetch, setting it removes the boundary and breaks the body.
  • res.text() then parse. xhr.responseType = "json" yields null for an empty body; res.json() throws SyntaxError: Unexpected end of JSON input. Parsing text first handles both.

Run both implementations against the same tests

One test suite exercising both implementations A shared test table of scenarios — success, HTTP 413, HTTP 500, network drop, slow response past the timeout, user cancel, empty 204 body — runs against both the XHR and fetch implementations, and each scenario must produce the same outcome from both. Same scenarios, same expected outcome, two implementations scenario table 201 with JSON 413 Payload Too Large 500 server error connection reset response after timeout user cancel 204 empty body uploadXhr legacy uploadFetch new outcomes must be equal
A behaviour-level test table catches the semantic differences a code review misses.
// 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

Four-step migration sequence Step one wraps the existing XHR code behind a promise interface. Step two writes the scenario tests. Step three adds the fetch implementation behind a flag and runs both in tests. Step four switches traffic gradually and removes XHR except where progress is needed. Wrap, test, add, switch 1. wrap XHR behind a promise interface 2. test scenario table passes on XHR 3. add fetch behind a flag, same tests pass 4. switch 1% → 100%, watch error rates Step 1 is the one people skip — and it is what lets every caller stay unchanged through the switch.
With a stable interface and a shared test table, the switch is a flag flip rather than a rewrite.

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.