Serverless-first. Built for cold starts, tight memory, and one binary.Deploy guide →

Fetch Client

fetch is global. It reads method / headers / body from the init object and returns a Promise<Response>. Everything else in init (signal, timeout, redirect) is ignored. Method names are exact-case standard tokens — lowercase "post" is sent as GET (see Troubleshooting).

Quick look

// client.js
(async () => {
  const res = await fetch("https://example.com/");
  console.log(res.status); // 200
  console.log(await res.text()); // body text
})();
ff client.js

Modules vs scripts

ff treats a file as an ES module only when its name ends in .mjs or its first code is import/export. A plain .js file runs as a classic script, where top-level await is a syntax error — so the runnable examples on this page wrap async code in (async () => { ... })(). Save any of them with a .mjs extension if you'd rather use top-level await as written.

GET with headers

// client.js
(async () => {
  const res = await fetch("https://api.example.com/users/42", {
    headers: { authorization: "Bearer TOKEN" },
  });

  console.log(res.status); // 200
  console.log(res.ok); // true when 200–299
})();

Check res.ok — HTTP error statuses resolve, they don't throw:

const res = await fetch("https://api.example.com/missing");
if (!res.ok) {
  console.log("failed:", res.status); // e.g. 404
}

Caution: fetch only throws on network failures (DNS, refused connection, too many redirects). A 404 or 500 still resolves — always check res.ok or res.status.

POST JSON

// client.js
(async () => {
  const res = await fetch("https://api.example.com/notes", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ text: "buy milk" }),
  });

  const data = await res.json();
  console.log(data); // e.g. { id: 1, text: "buy milk" }
})();
ff client.js

string bodies — and URLSearchParams, which coerces to its query string — reach the wire intact. Everything else is coerced with JS String() semantics: a FormData or Blob arrives as "[object Object]", an ArrayBuffer as "[object ArrayBuffer]", a Uint8Array([104, 105]) as "104,105".

Known issue: fetch does not multipart-encode FormData or send binary bodies as bytes. Build the payload yourself:

const params = new URLSearchParams({ name: "ada" });
await fetch("https://api.example.com/submit", {
  method: "POST",
  headers: { "content-type": "application/x-www-form-urlencoded" },
  body: params, // "name=ada" — URLSearchParams stringifies to its query
});

Reading the response

Every reader works on Response (and Request). Each body reads once — after that bodyUsed is true:

ReaderReturns
await res.text()string
await res.json()parsed JSON
await res.arrayBuffer()ArrayBuffer
await res.bytes()Uint8Array
await res.blob()Blob
await res.formData()FormData
const res = await fetch("https://example.com/");
console.log(res.url); // final URL after redirects
console.log(res.redirected); // true when redirected
console.log(res.headers.get("content-type"));
console.log((await res.bytes()).length);

Redirects

Redirects are followed automatically, up to 5 hops. After that fetch throws Too many redirects:

const res = await fetch("https://example.com/old-path");
console.log(res.redirected); // true
console.log(res.url); // where it landed

There is no redirect: "manual" mode — the init key is ignored.

HTTPS trust

Public CAs work out of the box. For private/self-signed CAs:

SetupHow
One-off scriptff client.js --ca ./ca.pem (--ca works with ff <file> and ff -e only)
Any run, incl. ff startFF_CA_FILE=./ca.pem ff start
Dev TLS pairingStarting TLS with --cert/FF_CERT also trusts that cert for fetch
FF_CA_FILE=./ca.pem ff client.js

Note: --ca is not accepted by ff start. Use FF_CA_FILE (or FF_CERT) there.

Practical example: synced mirror

Fetch a list, then POST each item onward, checking every status:

// mirror.js
(async () => {
  const src = await fetch("https://api.example.com/items");
  if (!src.ok) throw new Error(`source: ${src.status}`);

  for (const item of await src.json()) {
    const dst = await fetch("https://backup.example.com/items", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(item),
    });
    console.log(item.id, dst.status);
  }
})();
ff mirror.js

Reference: limits

AreaLimitWhen hit
Concurrent fetches16The 17th waits for a free slot
Body stage per slot64KBLarger bodies spill to chunks/heap
I/O timeout30sSlow servers fail
Redirects5 hops maxThrows Too many redirects
AbortNot supportedsignal / timeout init keys ignored
ProtocolHTTP/1.1No HTTP/2 client

Troubleshooting

404 resolves instead of throwing — by design. Check res.ok / res.status after every call.

Lowercase method: "post" arrives as GET — method tokens match exactly; anything unrecognized goes out as GET (the body is still attached). Use "POST".

FormData / Blob / Uint8Array body arrives mangled — bodies are string-coerced, not serialized ("[object Object]", "104,105"). Send string, URLSearchParams, or a hand-built multipart string instead.

Caught fetch error has no .message — network failures reject with plain strings ("Network error", "Invalid URL", "Too many redirects"). Log String(e).

Too many redirects — the chain exceeded 5 hops. Inspect res.url on the last good hop or fix the redirect loop server-side.

TLS verify fails on a private CA — pass --ca (scripts) or FF_CA_FILE (ff start). Confirm the PEM path is readable.

Hanging with 17+ parallel fetches — the 16-slot pool is full. Await in batches or raise concurrency by sequencing.