APIs: Concepts, Design, Requests, Responses, and Integration

Using the Fetch API for HTTP Requests

Learn how to use the browser Fetch API with async/await, JSON, forms, headers, errors, cancellation, CORS, authentication, caching, and streams.

The Fetch API is a browser interface for making HTTP requests and receiving responses through Promises. It is used to retrieve API data, submit forms, update application state, and load resources such as JSON, text, images, and files.

This lesson assumes familiarity with JavaScript functions, objects, arrays, Promises, async/await, try/catch, URLs, HTTP methods, headers, status codes, and JSON.

What Fetch Does

Calling fetch() starts a network request. It returns a Promise, which represents asynchronous work that will eventually complete or fail. The fulfilled value is a Response object, not the parsed API data itself.

Fetch is conceptually simpler than the older XMLHttpRequest API. XMLHttpRequest uses event handlers and mutable request state, while Fetch uses Promises and works naturally with async/await. Both can make HTTP requests; Fetch does not automatically treat HTTP error statuses as rejected Promises.

The Basic Request Flow

  1. Start a request with fetch(url).
  2. Receive a Response object when the network exchange completes.
  3. Check the HTTP status and response metadata.
  4. Read and parse the body with a method such as json() or text().
  5. Use the resulting value to update application state or the interface.
async function loadItems() {
  try {
    const response = await fetch('/api/items');

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const items = await response.json();
    console.log(items);
  } catch (error) {
    console.error('Could not load items:', error);
  }
}

The same flow can be written with Promise chaining:

fetch('/api/items')
  .then(response => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  })
  .then(items => console.log(items))
  .catch(error => console.error('Request failed:', error));

Understanding the Response Object

A Response represents the received HTTP response. Useful properties include:

  • ok: true for status codes from 200 through 299.
  • status: the numeric HTTP status, such as 200, 404, or 500.
  • statusText: the associated reason phrase when supplied by the environment.
  • headers: a Headers object containing response metadata.
  • url: the final response URL.
  • redirected: whether the request followed a redirect.
  • type: the response type, such as basic, cors, opaque, or error.
const response = await fetch('/api/profile');

console.log(response.status, response.statusText);
console.log(response.url, response.redirected, response.type);
console.log(response.headers.get('Content-Type'));

if (response.status !== 200) {
  throw new Error('The profile was not returned');
}

A response body is normally a one-use stream. After calling response.json(), the body is consumed and cannot normally be read again with response.text(). Use response.clone() before consumption when two independent consumers need the body.

Parsing Response Bodies

Every body-reading method returns a Promise. Choose the method that matches the expected payload and its Content-Type.

MethodResult typeTypical content typeCommon use
json()JavaScript valueapplication/jsonAPI data
text()Stringtext/plain or HTMLMessages, markup, source text
blob()BlobImages, PDFs, downloadsDisplaying or downloading a file
arrayBuffer()ArrayBufferBinary dataCustom binary processing
formData()FormDataForm-encoded responseReading form-style payloads
const json = await response.json();
const message = await response.text();
const imageBlob = await response.blob();
const bytes = await response.arrayBuffer();
const fields = await response.formData();

Request Configuration with RequestInit

The optional second argument to fetch() is a RequestInit options object. The default method is GET.

OptionPurposeTypical valuesImportant notes
methodChooses the HTTP actionGET, POST, PUT, PATCH, DELETEGET and HEAD should not have bodies
headersSupplies request metadataObject or HeadersSome headers are browser-controlled
bodySends request dataString, FormData, URLSearchParams, BlobNot for GET or HEAD
modeControls origin behaviorcors, same-origin, no-corsno-cors produces restricted responses
credentialsControls cookies and authentication informationsame-origin, include, omitCross-origin cookies require server permission
cacheInfluences the browser HTTP cachedefault, no-store, reload, no-cache, force-cache, only-if-cachedServer cache headers and browser support also matter
redirectControls redirect handlingfollow, error, manualBehavior can be restricted by the browser
referrerPolicyControls referrer informationno-referrer, same-originUse an appropriate privacy policy
signalConnects cancellationAbortSignalProduced by AbortController
integrityChecks a resource hashSubresource Integrity valueMost useful for trusted static resources
keepaliveAllows limited requests during page terminationtrueRequest size and runtime limitations apply
priorityHints relative request importancehigh, low, autoSupported only in some browsers

HTTP Methods and Sending Data

MethodTypical purposeRequest body usageExample API operation
GETRetrieve dataNo body; use query parametersList products
POSTCreate or trigger an operationUsually has a bodyCreate an order
PUTReplace a resourceUsually has a complete resourceReplace a profile
PATCHPartially update a resourceContains changed fieldsChange a display name
DELETERemove a resourceUsually no bodyDelete a comment

GET Query Parameters

const url = new URL('/api/search', location.origin);
url.searchParams.set('q', userText);
url.searchParams.set('page', '2');

const response = await fetch(url);

URLSearchParams safely encodes user-supplied values. It can also create URL-encoded form bodies:

const body = new URLSearchParams({ email: 'person@example.test', topic: 'support' });
await fetch('/contact', { method: 'POST', body });

JSON Requests

const response = await fetch('/api/items', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Accept': 'application/json'
  },
  body: JSON.stringify({ name: 'Example' })
});

Content-Type tells the server how to interpret the body. Use JSON.stringify() with application/json for JSON APIs.

FormData and File Uploads

const form = document.querySelector('form');
const data = new FormData(form);

await fetch('/api/upload', {
  method: 'POST',
  body: data
});

For multipart form data, do not manually set Content-Type: multipart/form-data. The browser adds the correct boundary parameter. Manually replacing it can make the server unable to separate fields and files.

Headers

Headers are metadata sent with requests and responses. Common request headers include Accept, which states preferred response formats; Content-Type, which identifies the request body format; Authorization, which carries application authentication information; and conditional headers such as If-None-Match or If-Modified-Since.

const headers = new Headers({
  Accept: 'application/json',
  'Content-Type': 'application/json'
});
headers.set('X-Request-ID', crypto.randomUUID());

const response = await fetch('/api/items', { headers });
console.log(response.headers.get('Content-Type'));

A plain object is also valid configuration:

fetch('/api/profile', {
  headers: {
    Accept: 'application/json',
    Authorization: `Bearer ${clientProvidedToken}`
  }
});

Browsers forbid or control certain headers, including headers related to connection management, host selection, and some security-sensitive metadata. JavaScript cannot freely impersonate another origin or override browser networking rules.

Error Handling

SituationDoes fetch reject?How to detect itRecommended handling
Network failureUsually yescatchShow a connection or retry state
Aborted requestYeserror.name === 'AbortError'Treat intentional cancellation separately
Non-2xx HTTP responseUsually no!response.ok or status checkTurn it into an application error
Malformed bodyParsing rejectsCatch around json() or another parserInspect content type and server output
async function readJson(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Request failed with HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error('API request failed', {
      name: error.name,
      message: error.message,
      url
    });
    throw error;
  }
}

Do not display raw server messages, stack traces, tokens, or sensitive request data to users. Give users a useful state such as “Unable to load items. Try again.” Log only safe diagnostic context.

Cancellation and Timeouts

AbortController cancels a Fetch request through its signal. This is useful when a component is removed, a route changes, or a newer search makes an older request irrelevant.

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);

try {
  const response = await fetch('/api/items', { signal: controller.signal });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return await response.json();
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('Request cancelled or timed out');
  } else {
    throw error;
  }
} finally {
  clearTimeout(timer);
}

Cleanup code should abort work that is no longer relevant. An intentional abort is not necessarily a failure to show to the user.

Cross-Origin Requests and CORS

The same-origin policy restricts browser scripts from freely reading resources from a different scheme, host, or port. CORS, or Cross-Origin Resource Sharing, is a server-controlled permission system that tells the browser which cross-origin requests may be read.

A simple cross-origin request may be sent directly if it uses an allowed method and limited request headers. Other requests cause a preflight: the browser sends an OPTIONS request describing the intended method and headers. The server must respond with suitable permission headers before the browser sends the actual request.

Request scenarioClient settingRequired server behaviorBrowser outcome if not allowed
Cross-origin readable request without cookiesUsually mode: 'cors'Return Access-Control-Allow-Origin for the requesting originResponse is blocked from script
Cross-origin request with non-simple method or headersmode: 'cors'Permit the preflighted method and headers, often for OPTIONSActual request is not sent or is blocked
Cross-origin cookie-authenticated requestcredentials: 'include'Allow the exact origin and return Access-Control-Allow-Credentials: trueResponse is blocked; cookies may not be usable
Opaque no-cors requestmode: 'no-cors'Limited server interactionScript cannot read an ordinary API response

no-cors is not a general CORS workaround. It produces a restricted opaque response whose status and body are not normally readable by JavaScript.

Credentials, Authentication, and Security

The credentials option controls whether cookies, HTTP authentication information, and client certificates accompany a request:

  • same-origin: include credentials for same-origin requests; this is the usual default.
  • include: include credentials for cross-origin requests when browser and server policies allow them.
  • omit: do not send credentials.

Cookie-based sessions keep the session identifier in a cookie and commonly use credentials: 'include' for cross-origin APIs. Token-based APIs often use an Authorization: Bearer ... header. A browser token must be treated as client-accessible; never embed private server credentials, cloud keys, or database secrets in browser code.

Cookie-authenticated state-changing requests require CSRF protection. Use appropriate SameSite cookie settings and server-side CSRF tokens or another robust origin-validation design. Also use HTTPS, validate input on the server, and treat response data as untrusted. Safely escape external text instead of inserting it as raw HTML.

Caching and Redirects

Fetch participates in browser HTTP caching. The cache option influences cache interaction, but server response headers such as Cache-Control, ETag, and Expires also determine behavior.

  • default: use normal browser cache rules.
  • no-store: avoid using or updating the cache for the request.
  • reload: request from the server and generally update the cache.
  • no-cache: permit cached data only after validation with the server.
  • force-cache: use a matching cached response when possible.
  • only-if-cached: use cache only; browser mode restrictions apply.

Redirects are normally followed with redirect: 'follow'. Use redirect: 'error' to reject redirects, or manual for environment-specific manual handling. Redirect behavior can be restricted for security and cross-origin reasons.

Reusable Request and Response Objects

Request, Response, Headers, and Body are related Fetch interfaces. A Request stores a URL and request configuration, while a Response stores status, headers, and a body.

const request = new Request('/api/items', {
  method: 'GET',
  headers: { Accept: 'application/json' },
  cache: 'no-cache'
});

const response = await fetch(request);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const items = await response.json();

Request and response bodies are streams and are normally consumed once. Clone an object before independent consumers read its body:

const response = await fetch('/api/items');
const copy = response.clone();
const data = await response.json();
const rawText = await copy.text();

Progressive Reading with ReadableStream

For large responses, response.body may expose a ReadableStream. A reader can process chunks progressively rather than waiting for the entire body.

const response = await fetch('/api/large-file');
if (!response.ok || !response.body) throw new Error('Streaming unavailable');

const reader = response.body.getReader();
let received = 0;

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  received += value.byteLength;
  console.log(`Received ${received} bytes`);
}

A Practical API Workflow

A reusable helper centralizes status checks, parsing, and useful error context:

async function apiRequest(url, options = {}) {
  const response = await fetch(url, {
    headers: { Accept: 'application/json', ...options.headers },
    ...options
  });

  const contentType = response.headers.get('Content-Type') || '';
  const body = contentType.includes('application/json')
    ? await response.json()
    : await response.text();

  if (!response.ok) {
    const error = new Error(`API request failed: HTTP ${response.status}`);
    error.status = response.status;
    error.details = body;
    throw error;
  }

  return body;
}

async function createItem(item) {
  return apiRequest('/api/items', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(item)
  });
}

A list interface should represent at least four states: loading, success with results, success with no results, and failure.

async function renderItems() {
  setState({ status: 'loading' });
  try {
    const items = await apiRequest('/api/items');
    setState({ status: items.length ? 'success' : 'empty', items });
  } catch (error) {
    setState({ status: 'error', message: 'Items could not be loaded.' });
  }
}

For pagination, send a page number or cursor in query parameters and preserve the server's next-page value. For filtering, encode each filter with URLSearchParams. For search boxes, debounce input so requests do not start on every keystroke, then abort the previous request or associate each response with a request identifier. This prevents an older, slower response from overwriting newer results.

Debounced Search with Cancellation

let controller;
let timer;

function search(query) {
  clearTimeout(timer);
  if (controller) controller.abort();

  timer = setTimeout(async () => {
    controller = new AbortController();
    const url = `/api/search?q=${encodeURIComponent(query)}`;

    try {
      const response = await fetch(url, { signal: controller.signal });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const results = await response.json();
      renderResults(results);
    } catch (error) {
      if (error.name !== 'AbortError') renderSearchError();
    }
  }, 250);
}

Compatibility and Runtime Differences

Fetch is available in modern browsers and in many server-side JavaScript runtimes. Browser Fetch is subject to same-origin policy, CORS, cookie rules, and browser-controlled headers. Server-side Fetch generally does not have the same browser CORS restriction and may handle credentials differently.

Check the documentation for the target browser or server runtime. Use a compatible fallback or polyfill only when an older environment that lacks Fetch must be supported.

Troubleshooting Common Problems

  • 404 or 500 reaches the success path: Fetch resolved because communication completed. Check response.ok before parsing.
  • CORS error: Configure the API server to allow the origin, method, headers, and credentials. Do not rely on no-cors.
  • response.json() fails: The body may be empty, malformed, HTML, or already consumed. Inspect status and Content-Type.
  • Unsupported media type: Match the body to its header. Use JSON serialization with application/json, or use FormData without manually setting its multipart header.
  • Cookies are absent: Check credentials, server CORS headers, and cookie SameSite and Secure attributes.
  • Unexpected abort: Check timeout and cleanup paths, and distinguish AbortError from other failures.
  • Old search results appear: Abort stale requests or verify a request identifier before updating state.
  • Server code works but browser code fails: Test in the browser context and configure the API for browser CORS and cookie behavior.

Fetch Outcome Checklist

  1. Confirm the URL and HTTP method.
  2. Set headers that describe the body and desired response.
  3. Serialize JSON with JSON.stringify().
  4. Use FormData for multipart forms and let the browser set the boundary.
  5. Check response.ok or an expected status.
  6. Choose the correct body parser.
  7. Catch network, abort, and parsing failures.
  8. Update loading, success, empty, and error states.
  9. Cancel work that is no longer relevant.
  10. Keep secrets out of browser code and configure CORS on the server.

See the Fetch API reference path for related API-oriented material.