Skip to content
Educora
Advanced24 min24 / 27

fetch and REST APIs

Talk to servers: how HTTP works, REST resources and methods, `fetch` with JSON, status codes, error handling, timeouts with `AbortController` and parallel requests.

Check yourself
In this lesson you will learn
  • Explain an HTTP request and response: method, URL, headers, body, status code
  • Send GET and POST requests with fetch, read JSON and handle HTTP and network errors correctly
  • Build robust async patterns: timeouts, retries, parallel requests and URLSearchParams

The weather forecast in an app, exchange rates, the feed in a social network — they all come from servers through an API. Your code sends an HTTP request, and the server answers with data, usually JSON. fetch is the modern built-in tool for this in browsers and in Node.js (since version 18).

HTTP in one minute

A request consists of a method, a URL, headers and sometimes a body. A response has a status code, headers and a body. The first digit of the status code gives the meaning: 2xx success, 3xx redirect, 4xx client error (a bad request), 5xx server error.

Text
POST /api/orders HTTP/1.1
Host: shop.example.com
Content-Type: application/json
Authorization: Bearer <token>

{"productId": 7, "quantity": 2}

HTTP/1.1 201 Created
Content-Type: application/json

{"id": 1043, "productId": 7, "quantity": 2, "status": "new"}
The request is on top and the server's response below. An empty line separates the headers from the body.
Definition
REST

A style of building APIs: data are resources with their own URLs (/users, /users/42), and actions are expressed with HTTP methods. Every request is independent (the server does not “remember” the previous one), and data usually travel as JSON.

MethodMeaningExample and success status
GETreadGET /users/42 → 200 OK
POSTcreatePOST /orders → 201 Created
PUT / PATCHreplace / partly updatePATCH /users/42 → 200 OK
DELETEdeleteDELETE /orders/1043 → 204 No Content
Error codes you will see often: 400 (bad data), 401 (login required), 403 (no permission), 404 (not found), 429 (too many requests), 500 and 503 (server trouble).

GET with fetch

fetch(url) returns a promise that is fulfilled with a Response object. It takes two steps: first you wait for the response (the headers arrive), then you read the body with response.json() — which is a promise too. The key point: fetch rejects only on a network failure (no internet, unknown domain, CORS). Responses with 404 and 500 still “arrive” successfully, so always check response.ok (status 200–299).

JavaScript
async function getUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}

try {
  const user = await getUser(42);
  console.log(user.name, user.city);
  await getUser(999999);
} catch (error) {
  console.log('Request failed:', error.message);
}
Expected output
Aysel Baku
Request failed: HTTP 404
api.example.com is a placeholder: a real API will have different field names. The runner on this page does not send real requests, so below we will imitate the server.

You can create a Response object in plain JavaScript. That lets you imitate a server and practise without the internet: fakeFetch returns a Response just like the real fetch, and your code cannot tell the difference. Tests do exactly the same.

JavaScript
const db = { 42: { id: 42, name: 'Aysel', city: 'Baku' } };

async function fakeFetch(url) {
  await new Promise((resolve) => setTimeout(resolve, 50));
  const id = Number(url.split('/').pop());
  const user = db[id];
  return new Response(JSON.stringify(user ?? { error: 'Not found' }), {
    status: user ? 200 : 404,
    headers: { 'Content-Type': 'application/json' },
  });
}

for (const id of [42, 7]) {
  const response = await fakeFetch(`/api/users/${id}`);
  console.log(response.status, response.ok, response.headers.get('content-type'));
  console.log(await response.json());
}
▸ Expected output
200 true application/json
{ id: 42, name: 'Aysel', city: 'Baku' }
404 false application/json
{ error: 'Not found' }

POST, headers and errors

To send data, pass an options object as the second argument of fetch: method, headers and body. The body must be text, so the object is turned into a string with JSON.stringify, and the Content-Type: application/json header tells the server the format. APIs that require login usually expect an Authorization: Bearer <token> header.

JavaScript
async function createOrder(order, token) {
  const response = await fetch('https://api.example.com/orders', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(order),
  });

  if (response.status === 401) throw new Error('Please log in again');
  if (!response.ok) throw new Error(`Server error ${response.status}`);
  return response.json();
}

const saved = await createOrder({ productId: 7, quantity: 2 }, token);
console.log('Created order', saved.id, saved.status);
Expected output
Created order 1043 new

Robust async patterns

Real networks are slow and unreliable. Four habits help: build query parameters with URL and URLSearchParams (special characters are encoded automatically); send independent requests in parallel with Promise.all; set a time limit — fetch(url, { signal: AbortSignal.timeout(8000) }) cancels the request after 8 seconds with a TimeoutError; and retry temporary failures (5xx, 429) with a growing pause.

JavaScript
const url = new URL('https://api.example.com/search');
url.searchParams.set('q', 'plov & dolma');
url.searchParams.set('city', 'Baku');
url.searchParams.set('page', 2);

console.log(url.toString());
console.log(url.searchParams.get('q'));
console.log(Object.fromEntries(url.searchParams));
▸ Expected output
https://api.example.com/search?q=plov+%26+dolma&city=Baku&page=2
plov & dolma
{ q: 'plov & dolma', city: 'Baku', page: '2' }
A hand-glued ?q=plov & dolma would break the request: & would be read as a new parameter. URLSearchParams encodes it as %26.
JavaScript
function slowServer(ms, signal) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => resolve('data'), ms);
    signal.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(new DOMException('Request took too long', 'TimeoutError'));
    });
  });
}

async function withTimeout(ms, limit) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), limit);
  try {
    return await slowServer(ms, controller.signal);
  } catch (error) {
    return `failed: ${error.name}`;
  } finally {
    clearTimeout(timer);
  }
}

console.log(await withTimeout(50, 200));
console.log(await withTimeout(500, 200));
▸ Expected output
data
failed: TimeoutError
AbortController creates a cancel signal; the real fetch accepts this signal. AbortSignal.timeout(ms) does the same job in one line.
Exercise

Write the getJSON(url) helper: it calls fakeFetch(url), throws an error with the message HTTP <status> if the response is not ok, and otherwise returns the parsed JSON.

Exercise · JavaScript
const products = { 1: { id: 1, title: 'Notebook', price: 5 } };

async function fakeFetch(url) {
  const id = Number(url.split('/').pop());
  const product = products[id];
  return new Response(JSON.stringify(product ?? { error: 'Not found' }), {
    status: product ? 200 : 404,
    headers: { 'Content-Type': 'application/json' },
  });
}

async function getJSON(url) {
  // call fakeFetch, check response.ok, return the JSON
}

for (const url of ['/api/products/1', '/api/products/99']) {
  try {
    console.log(await getJSON(url));
  } catch (error) {
    console.log('Error:', error.message);
  }
}
▸ Expected output
{ id: 1, title: 'Notebook', price: 5 }
Error: HTTP 404
Exercise

flakyFetch returns 503 the first two times. Write fetchWithRetry(url, retries): after every failed attempt it prints Attempt N: 503; on a 5xx error it waits 100, then 200 ms (doubling each time) and tries again; when the retries run out, or on a 4xx, it throws HTTP <status>.

Exercise · JavaScript
let calls = 0;
async function flakyFetch(url) {
  calls++;
  const ok = calls >= 3;
  return new Response(JSON.stringify(ok ? { rates: { USD: 1.7 } } : {}), { status: ok ? 200 : 503 });
}

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function fetchWithRetry(url, retries) {
  // only one attempt for now: add retries with a growing delay
  const response = await flakyFetch(url);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

const data = await fetchWithRetry('/api/rates', 3);
console.log('Rates:', data.rates);
▸ Expected output
Attempt 1: 503
Attempt 2: 503
Rates: { USD: 1.7 }

Key points

  • HTTP: a request = method + URL + headers + body; a response = status + headers + body. 2xx success, 4xx client error, 5xx server error.
  • REST: resources live at URLs and actions use methods: GET reads, POST creates, PUT/PATCH update, DELETE removes.
  • fetch rejects only on network failures — always check response.ok and read the body once with await response.json().
  • For POST send method, a Content-Type: application/json header and body: JSON.stringify(data).
  • Robustness: time limits (AbortSignal.timeout), retries for temporary 5xx errors, Promise.all for parallel requests and URLSearchParams for parameters.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
fetch is sent to an address that returns 404. What happens?