Modern Fetch API for Uploads

fetch is the default transport for browser uploads, but its ergonomics hide the two facts that decide whether your upload code survives production: the body you hand it is serialised by rules you do not control, and the promise it returns resolves on the response headers, not on the last byte you sent. Get those wrong and you ship a progress bar that jumps to 100% before anything has been stored, a retry loop that re-sends 400 MB on a 403, and a TypeError in Sentry that could mean any of four unrelated faults.

This guide covers the mechanism underneath fetch for upload-shaped requests: body serialisation, the request timeline, response disposition, and the RequestInit fields that matter when the payload is measured in megabytes. It sits inside upload fundamentals and browser APIs alongside the transport-level concerns in browser timeout and retry logic.

Prerequisites

  • [ ] A browser baseline of Chrome 105+, Firefox 115+ or Safari 16.4+ — AbortSignal.timeout() and AbortSignal.any() are assumed throughout.
  • [ ] Node 20+ if you want to run the verification harness at the end; it uses the built-in fetch, Blob and crypto.randomUUID().
  • [ ] TypeScript configured with "lib": ["DOM", "DOM.Iterable", "ES2022"], or plain ESM JavaScript with the type annotations removed.
  • [ ] An upload endpoint that accepts PUT with a binary body and returns JSON — a presigned URL works, and so does the 30-line Node server in the verification section.
  • [ ] A working CORS configuration if the endpoint is cross-origin; see CORS configuration for uploads before you debug anything else.

How it works

fetch(input, init) does not send your object. It constructs a Request, runs the extract a body algorithm over whatever you passed as body, and hands the resulting byte stream to the network layer. That extraction step is where most upload surprises originate, because it decides three things at once: what bytes go out, whether a Content-Type header is generated for you, and whether the browser can compute a Content-Length up front.

From BodyInit to bytes on the wire

BodyInit is a union of seven types, and each one takes a different path through the serialiser.

Body types and the headers fetch derives from them A three-column table mapping each BodyInit type to how the browser serialises it and the Content-Type header it generates. What the browser does with each body type You pass fetch serialises it as Header it sets for you File / Blob read off disk in 64 KiB chunks Content-Type: blob.type FormData multipart body + boundary multipart/form-data; boundary URLSearchParams percent-encoded key=value x-www-form-urlencoded ArrayBuffer / view copied into a new buffer none — you must set it ReadableStream pulled chunk by chunk none; needs duplex: half Only a ReadableStream body leaves Content-Length unknown — that request goes out chunked.
The serialiser only invents a Content-Type when it can derive one; raw buffers and streams arrive at the server as untyped octets unless you say otherwise.

Three consequences are worth internalising.

A File is a Blob backed by a file handle, not a heap buffer. Passing it straight to fetch means the browser reads it in small increments as the socket drains, so a 2 GB upload costs kilobytes of JavaScript heap. Calling await file.arrayBuffer() first costs 2 GB and will throw RangeError: Array buffer allocation failed on a mobile device. The File API and Blob objects guide covers the disk-backed lifecycle in detail; the rule for fetch is simply never materialise what you can stream.

The generated Content-Type comes from blob.type, which comes from the operating system’s guess at selection time. It is a hint, not a fact — an uploaded .mkv frequently arrives as an empty string, and a renamed .exe can arrive as image/png. Treat it as a routing hint only and validate server-side; why browser MIME types are unreliable has the evidence.

Content-Length is a forbidden header name. Setting it in headers is silently ignored, and the browser computes it from the extracted body. For everything except a stream body it is known before the first byte goes out, which is what lets a server reject an oversized upload at the headers with 413 Payload Too Large instead of reading 900 MB into /tmp first.

The promise resolves on headers, not on the last byte

This is the single most misread part of the API. await fetch(...) settles when the response status line and headers arrive. The body upload has already finished by then, but you were given no notification when it did, and the value you get back does not tell you how long it took.

Timeline of a fetch upload showing when each promise settles A horizontal timeline of connect, body upload, server work and response, marking that the fetch promise resolves only after response headers arrive. Where the fetch promise resolves connect + headers request body upload server work response await fetch() resolves here await res.json() resolves 0 s 0.3 s 13.1 s 14.2 s 14.3 s no progress events for these bytes 12.8 s of a 14.3 s upload is invisible to fetch
A 60 MB upload on a 40 Mbit uplink: the promise you await settles at 14.2 s, and every second before it looks identical from JavaScript.

Two practical rules fall out of that timeline. First, any timeout you wrap around fetch is a timeout on upload plus server work plus first response byte — it cannot distinguish “the uplink died at 40%” from “the server is slowly writing to S3”. A fixed 15 s deadline is therefore wrong for uploads: it will kill a healthy 200 MB transfer. Scale the deadline off the payload size, or drive it from progress instead of duration, as in aborting uploads with AbortController and timeouts.

Second, if the connection drops after the server committed the object but before the response headers came back, fetch rejects with a TypeError while the upload actually succeeded. That is a duplicate waiting to happen on the next attempt, and the reason every retryable upload endpoint needs a stable key — see retrying fetch uploads with idempotency keys.

Why there is no upload progress event

XMLHttpRequest exposes xhr.upload.onprogress because the request body is a value the object owns. In fetch, the request body is a stream handed to the network layer, and the spec never defined an observation point on it. There is no onprogress, no Request.prototype.uploaded, and no plan to add one.

The only true fix is to own the stream yourself: replace the File body with a ReadableStream you pipe through a counting TransformStream, which is exactly what tracking upload progress with a TransformStream walks through. Be honest about the cost, though. A stream body requires duplex: 'half', which as of mid-2026 is Chromium-only, requires HTTP/2 or HTTP/3 to the origin, and cannot be replayed on retry. The Streams API for uploads guide covers the capability gate.

If a progress bar is a hard product requirement across all browsers, the pragmatic answers are: keep XMLHttpRequest for the transfer, or chunk the file and derive progress from completed chunks — one fetch per 8 MB slice gives you 1/N-granularity progress with no streaming support required. That approach also feeds the reporting patterns in real-time upload progress events.

Connection limits shape parallel uploads

Over HTTP/1.1 the browser allows six concurrent connections per origin. Fire eight parallel part uploads and two of them sit in a queue — worse, your polling requests to the same origin queue behind 60 MB of body. Over HTTP/2 everything multiplexes on one connection, so the queueing disappears, but a saturated uplink still delays the control-plane requests sharing it, and the per-stream flow-control window (65 535 bytes by default until the peer raises it) means a server that reads slowly applies backpressure all the way to your fetch.

The practical settings: cap upload concurrency at 3–4 even on HTTP/2, and put your status/polling API on a different origin from the storage endpoint if latency there matters. Part sizing itself is covered in handling large file size limits.

Step-by-step implementation

The following six steps build one upload module. Each file is complete; together they are the code we run in production behind a presigned PUT.

1. Decide the body type before you write the request

Pick FormData when the server expects fields alongside the file, and a raw Blob when the destination is object storage. Multipart costs a base64-free but still non-trivial overhead — roughly 200 bytes of preamble per field plus a boundary — and forces the server through a parser. Raw binary costs nothing and lands as the exact object bytes. The trade-off, and the encoding cost of the alternative you should not pick, is in base64 vs binary encoding.

// upload/body.ts — decide what fetch should serialise before building the request.
export interface UploadBody {
  body: BodyInit;
  headers: Record<string, string>;
  /** Bytes on the wire, or null when the browser cannot know it up front. */
  contentLength: number | null;
}

/** Content-Type values that do NOT trigger a CORS preflight. */
const SAFELISTED_TYPES = new Set([
  'application/x-www-form-urlencoded',
  'multipart/form-data',
  'text/plain',
]);

const SAFELISTED_NAMES = new Set(['accept', 'accept-language', 'content-language']);

export function buildBody(file: File, mode: 'binary' | 'form'): UploadBody {
  if (mode === 'form') {
    const form = new FormData();
    form.append('file', file, file.name);
    form.append('clientName', file.name);
    // Deliberately no Content-Type: the browser must append its own boundary.
    return { body: form, headers: {}, contentLength: null };
  }

  const type = file.type || 'application/octet-stream';
  return {
    body: file, // a File is a Blob — fetch streams it off disk
    headers: {
      'Content-Type': type,
      // Header values are ISO-8859-1 only; encode anything the user typed.
      'X-Upload-Filename': encodeURIComponent(file.name),
    },
    contentLength: file.size,
  };
}

/** True when this header set forces an OPTIONS round trip before the upload. */
export function needsPreflight(headers: Record<string, string>): boolean {
  return Object.entries(headers).some(([name, value]) => {
    const lower = name.toLowerCase();
    if (lower === 'content-type') {
      return !SAFELISTED_TYPES.has(value.split(';')[0].trim().toLowerCase());
    }
    return !SAFELISTED_NAMES.has(lower);
  });
}

needsPreflight is not decoration. application/octet-stream is not on the CORS safelist, so the binary path always costs an OPTIONS round trip before a single byte of file moves. On a 250 ms RTT link that is a quarter-second of dead time per upload unless the server returns Access-Control-Max-Age: 86400.

2. Build a fresh Request per attempt

Constructing a Request object separately from fetch is worth the extra line: you can log request.headers, assert on request.method in a unit test, and keep the send path free of configuration.

What you must not do is reuse one Request across retries. new Request(existingRequest, init) and fetch(existingRequest, init) both mark the original’s body as used, and the second attempt fails with TypeError: Failed to execute 'fetch' on 'Window': Cannot construct a Request with a Request object that has already been used. request.clone() works but keeps both bodies alive in memory. Building a new Request around the same File is free — the file handle is what is shared, not the bytes.

// upload/request.ts
import { buildBody } from './body.js';

export interface AttemptContext {
  endpoint: string;
  file: File;
  mode: 'binary' | 'form';
  idempotencyKey: string;
  signal: AbortSignal;
}

export function createUploadRequest(ctx: AttemptContext): Request {
  const { body, headers } = buildBody(ctx.file, ctx.mode);

  return new Request(ctx.endpoint, {
    method: 'PUT',
    body,
    headers: new Headers({
      ...headers,
      'Idempotency-Key': ctx.idempotencyKey,
      Accept: 'application/json',
    }),
    signal: ctx.signal,
    credentials: 'omit', // a presigned URL must never carry cookies
    mode: 'cors',
    cache: 'no-store',
    redirect: 'error', // a redirect on an upload endpoint is a misconfiguration
    referrerPolicy: 'no-referrer',
    keepalive: false, // keepalive caps the body at 64 KiB
  });
}

Log one request before you trust the module:

PUT https://uploads.example.com/v1/objects/clip.mp4
content-type: video/mp4
idempotency-key: 6f1c0a1e-6a2b-4f0e-9a3d-1b7c2d4e5f60
x-upload-filename: holiday%20clip.mp4

Note what is absent: no Content-Length (the browser adds it), and no Origin (also browser-controlled). Both are forbidden header names and are dropped without warning if you set them.

3. Send with a size-scaled deadline

Combine the caller’s cancellation signal with a per-attempt timeout using AbortSignal.any(), and build the Request after combining, so the request carries the final signal.

// upload/send.ts
import { createUploadRequest, type AttemptContext } from './request.js';

export class UploadError extends Error {
  readonly status: number;
  readonly retryable: boolean;
  readonly retryAfterMs: number | null;

  constructor(message: string, status: number, retryable: boolean, retryAfterMs: number | null) {
    super(message);
    this.name = 'UploadError';
    this.status = status;
    this.retryable = retryable;
    this.retryAfterMs = retryAfterMs;
  }
}

/** 30 s floor, then one second of budget per 40 KB — a pessimistic 320 kbit/s. */
export function deadlineFor(sizeBytes: number): number {
  return Math.max(30_000, Math.ceil(sizeBytes / 40_000) * 1000);
}

export async function sendOnce(
  ctx: Omit<AttemptContext, 'signal'>,
  callerSignal: AbortSignal,
  deadlineMs: number,
): Promise<Response> {
  const timeout = AbortSignal.timeout(deadlineMs);
  const signal = AbortSignal.any([callerSignal, timeout]);
  const request = createUploadRequest({ ...ctx, signal });

  try {
    return await fetch(request);
  } catch (error) {
    if (timeout.aborted) {
      throw new UploadError(`no response within ${deadlineMs} ms`, 0, true, null);
    }
    if (callerSignal.aborted) throw error; // user cancellation — never retry this
    // Network-layer failure: DNS, TLS, connection reset, CORS rejection.
    throw new UploadError(`network failure: ${(error as Error).message}`, 0, true, null);
  }
}

Checking timeout.aborted before callerSignal.aborted is deliberate: both signals abort the same fetch, and only the order of these two checks tells a deadline from a user cancel. A cancel must never be retried; a deadline usually should be.

4. Classify the response before you touch its body

Disposition of a single fetch upload attempt One fetch attempt yields response headers that route to a terminal 4xx, a successful 2xx, or a retryable failure that loops back with backoff. Disposition of one fetch attempt fetch(request) one Request per attempt response headers status 400–499 status ≥ 500 / 408 2xx 4xx — terminal surface, never retry 2xx — done read the body once 5xx / 408 — retry backoff, same key retry attempt
Every attempt ends in exactly one of three states; only the right-hand branch is allowed to consume another attempt, and it must reuse the same idempotency key.

response.ok is true only for 200–299. Everything else is a value, not an exception — fetch rejects for network faults, never for HTTP status. Split the non-ok statuses into terminal and retryable, and honour Retry-After when the server sends one.

// upload/classify.ts
export type Disposition =
  | { kind: 'ok' }
  | { kind: 'terminal'; reason: string }
  | { kind: 'retryable'; delayMs: number | null };

// 425 Too Early and 429 Too Many Requests are retryable despite being 4xx.
const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504, 507]);

export function parseRetryAfter(value: string | null): number | null {
  if (!value) return null;
  const seconds = Number(value);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
  const at = Date.parse(value); // HTTP-date form
  return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
}

export function classify(response: Response): Disposition {
  if (response.ok) return { kind: 'ok' };
  if (RETRYABLE.has(response.status)) {
    return {
      kind: 'retryable',
      delayMs: parseRetryAfter(response.headers.get('Retry-After')),
    };
  }
  return { kind: 'terminal', reason: `HTTP ${response.status} ${response.statusText}` };
}

413 is deliberately terminal: re-sending the same oversized body cannot succeed, and the fix is a smaller part size or a raised proxy limit. 507 is retryable but only after a long delay — the storage backend is out of space and a 500 ms backoff will not help. Both are dissected in handling 413 and 507 errors during uploads.

5. Read the response body exactly once, defensively

response.json(), .text(), .arrayBuffer() and .blob() all lock and consume the same stream. Calling two of them throws TypeError: Failed to execute 'json' on 'Response': body stream already read. And the body is very often not JSON: an nginx 413 page, a Cloudflare challenge, or an S3 XML error will all crash response.json() with SyntaxError: Unexpected token '<', "<html>..." is not valid JSON — an error message that tells your on-call nothing.

// upload/read.ts
export async function readJson<T>(response: Response): Promise<T | null> {
  if (response.status === 204 || response.headers.get('Content-Length') === '0') {
    return null;
  }

  const contentType = response.headers.get('Content-Type') ?? '';
  const text = await response.text(); // one read, and it never throws on shape

  if (!contentType.toLowerCase().includes('json')) {
    throw new Error(
      `expected JSON, got "${contentType || 'no Content-Type'}": ${text.slice(0, 120)}`,
    );
  }

  try {
    return JSON.parse(text) as T;
  } catch {
    throw new Error(`malformed JSON (${text.length} bytes): ${text.slice(0, 120)}`);
  }
}

Reading as text first costs one string allocation and buys you the first 120 characters of whatever the proxy actually returned. In an incident that is the difference between a five-minute diagnosis and an hour.

One more reason to always read the body, even on failure: an unread response body holds the connection open until the garbage collector gets round to it. Draining it lets the socket be reused for the retry.

6. Assemble the retry loop

// upload/upload.ts
import { sendOnce, deadlineFor, UploadError } from './send.js';
import { classify } from './classify.js';
import { readJson } from './read.js';

export interface UploadResult {
  key: string;
  etag: string;
  bytes: number;
}

export async function uploadFile(
  endpoint: string,
  file: File,
  callerSignal: AbortSignal,
  maxAttempts = 4,
): Promise<UploadResult | null> {
  // One key for the whole upload, not one per attempt.
  const idempotencyKey = crypto.randomUUID();
  const ctx = { endpoint, file, mode: 'binary' as const, idempotencyKey };
  const deadlineMs = deadlineFor(file.size);
  let lastError: Error = new UploadError('upload never ran', 0, false, null);

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    try {
      const response = await sendOnce(ctx, callerSignal, deadlineMs);
      const disposition = classify(response);

      if (disposition.kind === 'ok') {
        return await readJson<UploadResult>(response);
      }

      const detail = (await response.text()).slice(0, 200); // drain, reuse the socket

      if (disposition.kind === 'terminal') {
        throw new UploadError(`${disposition.reason}${detail}`, response.status, false, null);
      }

      lastError = new UploadError(
        `HTTP ${response.status}${detail}`,
        response.status,
        true,
        disposition.delayMs,
      );
    } catch (error) {
      if (callerSignal.aborted) throw error;
      if (error instanceof UploadError && !error.retryable) throw error;
      lastError = error as Error;
    }

    if (attempt === maxAttempts) break;

    const hinted = lastError instanceof UploadError ? lastError.retryAfterMs : null;
    const backoff = hinted ?? (Math.min(30_000, 500 * 2 ** (attempt - 1)) + Math.random() * 500);
    console.warn(
      `upload attempt ${attempt}/${maxAttempts} failed (${lastError.message}); retrying in ${Math.round(backoff)} ms`,
    );
    await new Promise((resolve) => setTimeout(resolve, backoff));
  }

  throw lastError;
}

A failing run logs something you can act on:

upload attempt 1/4 failed (HTTP 503 — SlowDown...); retrying in 612 ms
upload attempt 2/4 failed (network failure: Failed to fetch); retrying in 1187 ms

crypto.randomUUID() requires a secure context. Over plain HTTP on a LAN IP it is undefined and you get TypeError: crypto.randomUUID is not a function; use https:// or a localhost origin in development.

Configuration reference

Every RequestInit field that changes upload behaviour, with the default the browser applies when you omit it.

Option Type Default Effect on an upload
method string GET Must be PUT or POST. A body on GET/HEAD throws TypeError: Request with GET/HEAD method cannot have body.
body BodyInit | null null Determines serialisation, the derived Content-Type, and whether Content-Length is known.
headers HeadersInit empty Forbidden names (Content-Length, Host, Origin, Connection, Cookie) are dropped silently. Values must be ISO-8859-1.
signal AbortSignal | null null Aborting rejects with DOMException: The user aborted a request. (name AbortError) or TimeoutError from AbortSignal.timeout().
credentials 'omit' | 'same-origin' | 'include' same-origin include forbids Access-Control-Allow-Origin: *, so it breaks most storage endpoints. Use omit with presigned URLs.
mode 'cors' | 'same-origin' | 'no-cors' cors no-cors yields an opaque response: status === 0, ok === false, no headers. Never usable for an upload you must confirm.
cache 'default' | 'no-store' | 'reload' | 'no-cache' default Irrelevant to the request body; no-store stops an intermediary caching the JSON receipt.
redirect 'follow' | 'error' | 'manual' follow follow re-sends the body on 307/308 and silently drops it on 303. error surfaces the misconfiguration instead.
referrerPolicy ReferrerPolicy strict-origin-when-cross-origin no-referrer keeps your app’s URL (often containing ids) out of the storage provider’s access logs.
keepalive boolean false Lets the request outlive the page, but the body is capped at 64 KiB across all in-flight keepalive requests.
duplex 'half' unset Mandatory when body is a ReadableStream; omitting it throws TypeError: Failed to execute 'fetch': Request with a ReadableStream body must have the duplex member set.
integrity string '' Subresource integrity on the response only. It cannot verify what you uploaded — checksum client-side instead.
priority 'high' | 'low' | 'auto' auto Chromium-only scheduling hint. low on background uploads keeps interactive requests ahead of them.

To verify the object actually stored matches what you sent, hash before upload and compare against the returned ETag: computing file checksums in the browser with Web Crypto covers the streaming SHA-256 path.

Edge cases and gotchas

Setting Content-Type yourself on a FormData body

The most common upload bug in the ecosystem. FormData generates a random boundary at serialisation time; if you set Content-Type: multipart/form-data manually, the header goes out without ; boundary=----WebKitFormBoundaryXyZ, and the server cannot split the parts. busboy fails with Error: Multipart: Boundary not found, multer surfaces MulterError: Unexpected field or an empty req.files, and Spring returns 400 with the request was rejected because no multipart boundary was found.

The fix is to delete the header, not to guess the boundary. If you are merging default headers into every request, filter content-type out on the multipart path. Multipart form data explained shows the byte layout the boundary is delimiting.

“Failed to fetch” is four faults wearing one mask

A rejected fetch gives you TypeError: Failed to fetch (Chromium), TypeError: NetworkError when attempting to fetch resource. (Firefox), or TypeError: Load failed (Safari). The message is deliberately vague — exposing the real cause would leak cross-origin information — so the error object alone can never tell you what happened.

Four distinct causes behind a single Failed to fetch TypeError A single TypeError box fans out to four causes: CORS preflight rejection, the request never leaving the machine, being blocked before send, and a body stream error. TypeError "Failed to fetch" status is never set Only the Network panel tells these apart CORS preflight rejected a red OPTIONS row appears before your request The request never left the machine DNS failure, TLS error, or the device is offline Blocked before it was sent mixed content, an extension, or a CSP connect-src rule The body errored mid-flight a stream body threw after the headers went out
Instrument all four separately: log navigator.onLine, whether an OPTIONS row exists, and the elapsed time before the rejection — a sub-5 ms failure was never on the network.

The cheap discriminator is elapsed time. Record performance.now() before the call; a rejection under ~5 ms was blocked locally (CSP, mixed content, an extension), while a rejection at 200 ms+ genuinely touched the network. Combine that with navigator.onLine and a count of preflight failures and you can triage without a repro. The CORS branch specifically is walked through in fixing CORS preflight errors on S3 uploads.

keepalive caps the body at 64 KiB

keepalive: true is tempting for “finish this upload even if the user closes the tab”, but the spec caps the combined body size of all in-flight keepalive requests at 64 KiB. Exceed it and Chrome throws synchronously before any bytes move: TypeError: Failed to execute 'fetch' on 'Window': keepalive request has too large a body. It is a beacon mechanism, not an upload mechanism. Use it for the “upload abandoned” telemetry ping, never for the file.

Redirects replay the body — or throw it away

With the default redirect: 'follow', a 307 or 308 re-sends the entire body to the new location, so a misconfigured bucket alias can silently double your egress. A 301, 302 or 303 rewrites the request to GET and drops the body entirely, so the server receives an empty request and answers 400, and response.ok may still be true for a redirect target that returns 200. The browser follows up to 20 hops, and a cross-origin hop needs its own CORS approval.

For an upload endpoint the correct posture is redirect: 'error'. You want a loud TypeError in staging, not a mystery doubling of your S3 bill. If you legitimately upload through a redirecting gateway, use 'manual' and inspect response.type === 'opaqueredirect' yourself. Note also that response.redirected and response.url are the only evidence a follow happened.

A stream body cannot be retried, and a Blob body can

ReadableStream bodies are single-use: once the network layer has pulled from them, the retry has nothing to send and fetch rejects immediately. If you adopt streaming uploads for progress reporting, your retry unit must be a chunk you can re-create — slice the file again with Blob.slice() and build a new stream per attempt. Blob and File bodies do not have this problem because the browser re-reads from the file handle.

This is the concrete reason chunked uploads and streaming uploads are different designs rather than the same one, and it is covered from the streaming side in the Streams API for uploads guide.

An abort that lands after the server committed

controller.abort() tears down the socket, but it cannot un-write an object. If the abort lands in the window between “the server finished writing to storage” and “the response headers reached the browser”, you have an object in the bucket and an AbortError in your logs. Two mitigations, both cheap: send the same Idempotency-Key on retries so a duplicate is collapsed server-side, and set a lifecycle rule that reaps unreferenced objects — see expiring incomplete multipart uploads automatically for the equivalent on the multipart path.

Presigned PUT versus presigned POST changes the body type

If your endpoint is a presigned POST policy rather than a presigned PUT, you must send FormData with the policy fields appended before the file field, in that order, because S3 ignores anything after the file part. Presigned PUT takes the raw Blob and no fields at all. Choosing between them is a server-side decision with client-side consequences: presigned POST vs presigned PUT for browser uploads lays out the trade-off.

Header values are Latin-1, not UTF-8

new Headers({ 'X-Upload-Filename': 'résumé.pdf' }) throws TypeError: Failed to construct 'Headers': String contains non ISO-8859-1 code point. in some engines and mangles the value in others. Percent-encode any user-supplied header value on the way out and decode it on the way in — or better, keep filenames in the body or the URL path rather than a header.

Verification

Three checks, from cheapest to most thorough.

Prove the wire format with curl. This replicates the binary path exactly, including the timing breakdown that tells you how long the body took versus the server:

curl -X PUT "https://uploads.example.com/v1/objects/demo.bin" \
  -H "Content-Type: application/octet-stream" \
  -H "Idempotency-Key: 6f1c0a1e-6a2b-4f0e-9a3d-1b7c2d4e5f60" \
  --data-binary @demo.bin \
  -o /dev/null -sS \
  -w 'status=%{http_code} sent=%{size_upload}B up=%{speed_upload}B/s ttfb=%{time_starttransfer}s total=%{time_total}s\n'

A healthy 10 MB upload prints something like status=200 sent=10485760B up=4106881B/s ttfb=2.61s total=2.62s. If ttfb and total are nearly equal the server responded as soon as the body landed; a large gap means the server is doing synchronous work you should move to a queue.

Prove the timings in the browser. The Resource Timing entry separates upload from server time without opening DevTools:

const entry = performance
  .getEntriesByType('resource')
  .find((e) => e.name.includes('/v1/objects/'));

console.table({
  connectMs: Math.round(entry.connectEnd - entry.connectStart),
  uploadPlusServerMs: Math.round(entry.responseStart - entry.requestStart),
  responseMs: Math.round(entry.responseEnd - entry.responseStart),
  totalMs: Math.round(entry.duration),
});

Cross-origin entries are zeroed unless the server sends Timing-Allow-Origin: *; add that header to your storage bucket’s CORS response if these numbers come back as 0.

Prove the whole module end to end. This harness runs on Node 20+ with no dependencies, and asserts the three things that break most often: the byte count, the derived Content-Type, and the idempotency header:

// verify-upload.mjs — run with: node verify-upload.mjs
import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';
import assert from 'node:assert/strict';

const seen = [];
const server = createServer((req, res) => {
  let bytes = 0;
  req.on('data', (chunk) => {
    bytes += chunk.length;
  });
  req.on('end', () => {
    seen.push({
      method: req.method,
      contentType: req.headers['content-type'],
      declaredLength: Number(req.headers['content-length'] ?? -1),
      receivedBytes: bytes,
      idempotencyKey: req.headers['idempotency-key'],
    });
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ key: 'demo/object.bin', etag: '"9f86d081"', bytes }));
  });
});

await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();

const payload = randomBytes(3 * 1024 * 1024);
const blob = new Blob([payload], { type: 'application/octet-stream' });
const key = crypto.randomUUID();

const response = await fetch(`http://127.0.0.1:${port}/v1/objects/demo.bin`, {
  method: 'PUT',
  body: blob,
  headers: { 'Content-Type': blob.type, 'Idempotency-Key': key },
});

const result = await response.json();
const [observed] = seen;

assert.equal(response.status, 200);
assert.equal(observed.method, 'PUT');
assert.equal(observed.contentType, 'application/octet-stream');
assert.equal(observed.declaredLength, payload.byteLength);
assert.equal(observed.receivedBytes, payload.byteLength);
assert.equal(observed.idempotencyKey, key);

console.log('server receipt:', result);
console.log('observed request:', observed);
server.close();

Expected output:

server receipt: { key: 'demo/object.bin', etag: '"9f86d081"', bytes: 3145728 }
observed request: {
  method: 'PUT',
  contentType: 'application/octet-stream',
  declaredLength: 3145728,
  receivedBytes: 3145728,
  idempotencyKey: '…'
}

If declaredLength comes back as -1, the browser or runtime could not size your body — you passed a stream where you meant to pass a Blob. If it is 3145728 but receivedBytes is smaller, the connection was cut mid-body and your server accepted a truncated object; add a length check before you commit anything to storage, as described in server-side file validation.

For the FormData variant of this harness, and the boundary assertion that goes with it, see uploading files with fetch and FormData.

Frequently Asked Questions

Should I use fetch or XMLHttpRequest for file uploads in 2026?

Use fetch unless you need a byte-accurate progress bar on every browser, in which case XMLHttpRequest is still the only universal answer because of xhr.upload.onprogress. Everything else — cancellation, streaming bodies, promise ergonomics, request introspection — is better in fetch. Many production apps run both: fetch for control-plane calls and small payloads, XHR behind a thin wrapper for the transfer itself.

Why does my upload succeed in Postman but fail with “Failed to fetch” in the browser?

Postman is not a browser: it does not enforce CORS, does not run preflights, and does not apply mixed-content or Content-Security-Policy rules. A request that works there and fails in the page is almost always one of those four browser-only checks. Open the Network panel and look for a red OPTIONS row immediately before your request — if it is there, the fault is your endpoint’s CORS response, not your JavaScript.

Does passing a 2 GB File to fetch load it into memory?

No. A File is disk-backed, and the browser reads it incrementally as the socket accepts data, so heap usage stays in the low megabytes. You only pay the full size if you convert it first with arrayBuffer(), text(), or a base64 encode. Concatenating multiple Blobs with new Blob([a, b]) is also cheap — it stores references, not copies.

Can I set a per-request timeout without AbortController?

AbortSignal.timeout(ms) is the built-in shortcut and needs no controller, but it is still an abort signal, so it aborts everything it is attached to. There is no RequestInit.timeout field and there will not be one. For uploads, prefer a deadline scaled to payload size, or a stall detector that resets whenever bytes move — a fixed wall-clock timeout kills healthy large transfers.

Why does the same fetch upload work on localhost but 403 against S3?

Presigned URLs sign a specific method, key and often specific headers. Adding a header the signature did not cover — a custom x-amz-meta-*, or a Content-Type different from the one signed — invalidates it, and S3 answers 403 with <Code>SignatureDoesNotMatch</Code> in the XML body. Read the body before you guess: your readJson helper surfacing the first 120 characters will name the mismatched field.