Fetch

A web page rarely needs to sit still. You might want to pull fresh data from a server, save a form, or check for new messages while the user keeps reading. JavaScript can do all of that by sending network requests in the background, then updating the page with whatever comes back — no full reload required.

Concrete things you reach for this feature to do:

  • Submit an order,
  • Load a user’s profile,
  • Poll the server for the latest updates,
  • …and plenty more.

The historical name for this pattern is AJAXAsynchronous JavaScript And XML. The “XML” part is a fossil from the early 2000s, when servers often replied with XML. These days the payload is almost always JSON, but the acronym stuck. If you see “AJAX” in older articles or job listings, it just means “JavaScript talking to a server without reloading.”

There are several APIs for making these requests. fetch() is the modern one: promise-based, flexible, and supported across every current browser. Very old browsers lack it, but a polyfill fills the gap when you need one.

The shape of a fetch call

let promise = fetch(url, [options])
  • url — the address you want to hit.
  • options — an optional settings object: HTTP method, headers, body, and more.

Leave options out and you get a plain GET request that downloads whatever lives at url. The browser fires the request immediately and hands you back a promise. Your code waits on that promise to find out what happened.

The part that trips people up: getting the full result is a two-stage process, and each stage is its own promise.

1. fetch(url)
promise resolves when the server sends back
status + headers
body not read yet
2. response.json()
second promise resolves when the
body is downloaded + parsed
A fetch resolves in two steps. The first promise settles when headers arrive; the body comes from a second call.

Stage one: the response arrives

The promise from fetch resolves with a Response object the moment the server replies with headers.

At this point the response line and headers are in — but the body may still be traveling over the wire. You can already inspect the HTTP status and read headers; you just can’t touch the body content yet.

One rule catches everyone at least once: a bad HTTP status is not an error to fetch. The promise rejects only when the request itself fails to complete — no network, DNS failure, a server that never answers. A 404 Not Found or 500 Internal Server Error counts as a successful round trip. The server answered; the answer was just unhappy. So you check the status yourself.

Two properties do that:

  • status — the numeric HTTP code, e.g. 200.
  • ok — a boolean, true when status falls in the 200–299 range.
let response = await fetch(url);

if (response.ok) { // status is 200-299
  // body-reading method covered just below
  let json = await response.json();
} else {
  alert("HTTP-Error: " + response.status);
}
promise REJECTS
no network connection
DNS / host not found
request never completes
promise RESOLVES
200 OK → response.ok === true
404 Not Found → response.ok === false
500 Server Error → response.ok === false
When fetch rejects vs. when it resolves with a non-ok status.

You can feel this rule out for yourself. A Response object doesn’t have to come from the network — you can construct one directly, which is perfect for seeing how status drives ok. Type any HTTP code and watch which branch your code would take:

interactivestatus decides response.ok

Stage two: reading the body

Once you have the Response, a second method call pulls in and decodes the body. Response exposes several promise-returning methods, each interpreting the bytes differently:

  • response.text() — read the body as a plain text string,
  • response.json() — parse the body as JSON and hand back the resulting value,
  • response.formData() — return a FormData object (covered in the next chapter),
  • response.blob() — return a Blob: binary data tagged with a MIME-type,
  • response.arrayBuffer() — return an ArrayBuffer: the low-level byte representation,
  • and response.body is a ReadableStream, letting you consume the body chunk by chunk (there’s an example of streaming in a later chapter).

Here’s a real request. This pulls a list of recent commits from a public GitHub API and parses the JSON:

let url = 'https://api.github.com/repos/example/example-repo/commits';
let response = await fetch(url);

let commits = await response.json(); // read the body, parse it as JSON

alert(commits[0].author.login);

The same thing with plain promise chaining instead of await:

fetch('https://api.github.com/repos/example/example-repo/commits')
  .then(response => response.json())
  .then(commits => alert(commits[0].author.login));

Notice how response.json() returns a promise, so the .then chain has two steps: one for the headers, one for the parsed body. That mirrors the two awaits exactly.

Want the raw text instead of parsed JSON? Swap in response.text():

let response = await fetch('https://api.github.com/repos/example/example-repo/commits');

let text = await response.text(); // read the body as one big string

alert(text.slice(0, 80) + '...');

For a binary example, here’s fetching an image and displaying it. response.blob() gives you a Blob, and URL.createObjectURL turns that into a URL an <img> can load (see the Blob chapter for the details):

let response = await fetch('/article/fetch/logo-fetch.svg');

let blob = await response.blob(); // download as a Blob object

// build an <img> to hold it
let img = document.createElement('img');
img.style = 'position:fixed;top:10px;left:10px;width:100px';
document.body.append(img);

// point it at the blob
img.src = URL.createObjectURL(blob);

setTimeout(() => { // remove it after three seconds
  img.remove();
  URL.revokeObjectURL(img.src); // release the blob URL
}, 3000);
response body
(stream, unread)
.text() ✓
drains the stream
.json() ✗
nothing left
The body is a one-shot stream. The first reader drains it; the second finds it empty.

Here’s that one-shot rule made real. The first button reads the body twice and catches the failure; the second calls clone() first, so both reads succeed. Same Response objects fetch would hand you:

interactivea body can only be read once

Response headers

The response headers live in response.headers, a Map-like object. It isn’t a real Map, but it behaves like one: look up a header by name, or iterate over every header.

let response = await fetch('https://api.github.com/repos/example/example-repo/commits');

// grab a single header
alert(response.headers.get('Content-Type')); // application/json; charset=utf-8

// walk through all of them
for (let [key, value] of response.headers) {
  alert(`${key} = ${value}`);
}

Header names here are case-insensitive, so get('Content-Type') and get('content-type') return the same value.

Try it below. The response carries three headers; look any of them up in any casing, or list them all with a for..of loop:

interactivereading response.headers

Request headers

To attach headers to the outgoing request, pass a headers option — an object mapping header names to values:

let response = fetch(protectedUrl, {
  headers: {
    Authentication: 'secret'
  }
});

There’s a catch. The browser keeps tight control over a set of forbidden request headers that your code is not allowed to set:

  • Accept-Charset, Accept-Encoding
  • Access-Control-Request-Headers
  • Access-Control-Request-Method
  • Connection
  • Content-Length
  • Cookie, Cookie2
  • Date
  • DNT
  • Expect
  • Host
  • Keep-Alive
  • Origin
  • Referer
  • TE
  • Trailer
  • Transfer-Encoding
  • Upgrade
  • Via
  • Proxy-*
  • Sec-*

These headers govern how HTTP itself behaves and how requests are routed and secured. Letting page scripts forge them would be a security and correctness hole, so the browser owns them exclusively. Try to set one and the browser quietly ignores it.

POST requests

For a POST — or any method other than GET — you flesh out the options object:

  • method — the HTTP method, e.g. POST,
  • body — the request payload, one of:
    • a string (often JSON-encoded),
    • a FormData object, sent as multipart/form-data,
    • a Blob or BufferSource for binary data,
    • a URLSearchParams object, for x-www-form-urlencoded data (rare).

JSON is what you’ll reach for most of the time. This example serializes a user object and posts it:

let user = {
  name: 'Maya',
  surname: 'Vance'
};

let response = await fetch('/article/fetch/post/user', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  },
  body: JSON.stringify(user)
});

let result = await response.json();
alert(result.message);

Two details are doing quiet work here.

body has to be a string of bytes, not an object — that’s why JSON.stringify(user) wraps the object. And when body is a string, fetch defaults the Content-Type to text/plain;charset=UTF-8. Since we’re actually sending JSON, we override it with application/json so the server parses the payload correctly. Skip that header and many servers will misread the body.

JS value
{ name: ‘Maya’ }
JSON.stringify →
body (string)
‘{“name”:“Maya”}’
+ header →
Content-Type
application/json
Building a JSON POST: serialize the object, then declare its type.

Sending an image

Binary payloads work the same way — hand fetch a Blob or BufferSource as the body. Here’s a <canvas> you can scribble on with the mouse; the “Submit” button ships the drawing to the server as a PNG:

<body style="margin:0">
  <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas>

  <input type="button" value="Submit" onclick="submit()">

  <script>
    canvasElem.onmousemove = function(e) {
      let ctx = canvasElem.getContext('2d');
      ctx.lineTo(e.clientX, e.clientY);
      ctx.stroke();
    };

    async function submit() {
      let blob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));
      let response = await fetch('/article/fetch/post/image', {
        method: 'POST',
        body: blob
      });

      // the server replies with a confirmation and the image size
      let result = await response.json();
      alert(result.message);
    }

  </script>
</body>

There’s no Content-Type header in that fetch call, and that’s deliberate. A Blob already carries its own type — image/png here, because that’s what we passed to toBlob. For a Blob body, fetch uses that built-in type as the Content-Type automatically, so setting it by hand would just duplicate what the browser already knows.

Scribble on the canvas below and hit Export as Blob. We stop short of the network, but this is exactly the Blob you’d pass as the fetch body — note the type it carries on its own:

interactivecanvas drawing to a PNG Blob

The same submit() written with promise chaining instead of async/await:

function submit() {
  canvasElem.toBlob(function(blob) {
    fetch('/article/fetch/post/image', {
      method: 'POST',
      body: blob
    })
      .then(response => response.json())
      .then(result => alert(JSON.stringify(result, null, 2)))
  }, 'image/png');
}

Summary

A typical fetch is two awaits: one for the headers, one for the body.

let response = await fetch(url, options); // resolves with response headers
let result = await response.json(); // reads body as json

Or as a promise chain:

fetch(url, options)
  .then(response => response.json())
  .then(result => /* process result */)

Response properties worth knowing:

  • response.status — the HTTP status code,
  • response.oktrue when the status is 200–299,
  • response.headers — a Map-like object of the response headers.

Methods to read the body (pick exactly one per response):

  • response.text() — the body as text,
  • response.json() — the body parsed as JSON,
  • response.formData() — the body as a FormData object (multipart/form-data; see the next chapter),
  • response.blob() — the body as a Blob (binary data with a type),
  • response.arrayBuffer() — the body as an ArrayBuffer (low-level binary data).

Request options covered so far:

  • method — the HTTP method,
  • headers — an object of request headers (with a browser-controlled forbidden list),
  • body — the data to send: a string, FormData, BufferSource, Blob, or URLSearchParams.

The chapters ahead layer on more fetch options and real-world scenarios — tracking progress, aborting requests, and handling cross-origin calls.