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

Fetch API

Learn to use the JavaScript Fetch API for HTTP requests, JSON, forms, authentication, CORS, cancellation, streaming, caching, and reusable API clients.

The Fetch API is a browser interface for making HTTP requests and receiving responses through promises. It lets a web application communicate with HTTP resources such as JSON APIs, HTML documents, images, and files without navigating away from the current page.

Fetch is built around Promises: objects representing asynchronous operations that later fulfill or reject. Its async/await syntax makes request code read in a sequential style. Older code commonly used XMLHttpRequest, which relies on event handlers and a more complicated control flow. Fetch generally provides a clearer interface, although libraries or XMLHttpRequest may still be useful for features such as upload-progress events.

How a fetch request works

A call to fetch(url) starts an HTTP request and immediately returns a promise. The promise resolves to a Response object when the browser receives an HTTP response. Receiving an HTTP response does not mean the operation succeeded: a 404 or 500 response normally still fulfills the promise. Your code must inspect the response status.

const response = await fetch('/api/items');

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

const items = await response.json();

The response body is read separately. This two-step design allows you to inspect status and headers before choosing how to interpret the body.

GET with async/await

async function loadItems() {
  try {
    showLoadingState();

    const response = await fetch('/api/items');
    if (!response.ok) {
      throw new Error(`Request failed with status ${response.status}`);
    }

    const items = await response.json();
    renderItems(items);
  } catch (error) {
    console.error('Loading items failed', error);
    showErrorState('Items could not be loaded.');
  }
}

A useful UI lifecycle is idle, loading, success, error, and cancelled. Render these states explicitly instead of leaving users with a permanently spinning indicator.

GET with a promise chain

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

The Response object

A Response represents the server's reply. Its status, headers, URL, and body determine how the application should proceed.

MemberWhat it providesTypical useImportant caveat
okBoolean indicating a status from 200 through 299Check for a successful HTTP responseIt is false for 3xx, 4xx, and 5xx statuses
statusNumeric HTTP status codeHandle 201, 204, 401, 404, or 500 specificallyIt does not itself throw an exception
statusTextHTTP reason text, such as “OK”DiagnosticsDo not depend on it for application logic
headersA Headers collectionRead content type, pagination, or caching metadataSome headers are hidden by browser security rules
urlFinal response URLInspect redirects or diagnosticsIt may differ from the requested URL
redirectedWhether a redirect was followedDetect redirect behaviorUse redirect options when policy matters
typeResponse category, such as basic, cors, opaque, or errorUnderstand browser handlingAn opaque response exposes very little information
json()Parses the body as JSONJSON APIsRejects if the body is empty or invalid JSON
text()Reads the body as textPlain text, HTML, or safe diagnostic outputThe body is consumed
blob()Reads a BlobImages, downloads, and other filesThe body is consumed
arrayBuffer()Reads binary bytesBinary protocols or custom processingCan use substantial memory for large data
formData()Parses a form-data responseResponses encoded as form dataThe response format must actually be form data
bodyA readable response streamProcess large responses incrementallyIt can be consumed only once unless cloned first

Reading a body once

Response bodies are streams. Calling json(), text(), blob(), arrayBuffer(), or formData() consumes the body. A second read normally fails; response.bodyUsed indicates whether it has been consumed.

const response = await fetch('/api/profile');
const profile = await response.json();
// Do not call response.text() here: the body was already consumed.

If two independent consumers need the body, clone before reading:

const response = await fetch('/api/report');
const copy = response.clone();

const data = await response.json();
const rawText = await copy.text();

Cloning duplicates the readable response path; it is not a substitute for storing a parsed value when the same result can be reused.

HTTP methods and request options

The second argument to fetch is an options object. An HTTP method describes the requested operation: GET retrieves data, POST commonly creates or triggers an operation, PUT replaces a resource, PATCH partially updates one, DELETE removes one, and HEAD retrieves headers without a response body. OPTIONS describes supported communication and is also used by browsers for CORS preflight requests.

OptionPurposeTypical valuesUsage notes
methodSelects the HTTP operationGET, POST, PUT, PATCH, DELETE, HEAD, OPTIONSGET and HEAD normally have no body
headersSupplies request metadataObject or HeadersSome headers are controlled by the browser
bodySends request dataString, FormData, URLSearchParams, Blob, bytesDo not send a body with GET or HEAD
credentialsControls cookies and credentialssame-origin, include, omitCross-origin cookies also require server permission
modeControls cross-origin behaviorcors, same-origin, no-corsno-cors does not bypass CORS or expose a normal response
cacheInfluences the HTTP cachedefault, no-store, reload, no-cache, force-cache, only-if-cachedUse fresh-data modes deliberately
redirectControls redirect handlingfollow, error, manualRedirect policy affects authentication and URL assumptions
signalConnects an AbortSignalcontroller.signalAborting rejects the fetch promise
referrerPolicyControls referrer informationno-referrer, strict-origin-when-cross-originChoose a policy appropriate for privacy
integrityVerifies a known response hashSubresource integrity hashUseful when fetching a resource with a trusted expected digest
keepaliveAllows limited requests to continue during page shutdowntrueRequest body size and browser limits apply

Request objects

A Request is a reusable representation of a URL and request settings, including method, headers, body, and signal.

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

const response = await fetch(request);

Sending request data

JSON APIs

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

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

Content-Type describes the request body. Accept tells the server which response formats the client prefers. A successful creation commonly returns 201 Created.

URL-encoded form data and query strings

URLSearchParams encodes key-value pairs using URL-encoded form syntax. It is useful for form bodies and for query strings used by filters, searches, and pagination.

const params = new URLSearchParams({
  query: 'web development',
  page: '2',
  limit: '20'
});

const response = await fetch(`/api/items?${params}`);

const formBody = new URLSearchParams({ email: 'user@example.test' });
await fetch('/api/subscribe', {
  method: 'POST',
  headers: { Accept: 'application/json' },
  body: formBody
});

The browser supplies the appropriate URL-encoded content type for a URLSearchParams body. Encode user-derived values with URLSearchParams rather than concatenating untrusted strings into URLs.

Multipart forms and file uploads

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

const response = await fetch('/api/uploads', {
  method: 'POST',
  body: data
});

FormData creates a multipart body and can include fields and files from a file input. Do not manually set Content-Type for a FormData request. The browser must add the multipart boundary; replacing the header manually can make the body unparsable.

Data typeBody constructionContent-Type handlingTypical use
JSONJSON.stringify(value)Set application/jsonStructured API payloads
URL-encoded datanew URLSearchParams(values)Browser can set the appropriate typeSimple forms and query parameters
Multipart form datanew FormData(form)Let the browser set itForms with files
Plain textA stringSet text/plain when requiredText endpoints or webhooks
Binary dataBlob, ArrayBuffer, or typed bytesUse the server's expected binary typeBinary APIs and file transfer

Headers

Headers are metadata sent with requests and responses. Create them as an object or with the Headers class.

const requestHeaders = new Headers({
  Accept: 'application/json',
  'Accept-Language': 'en-US'
});

const response = await fetch('/api/profile', {
  headers: requestHeaders
});

console.log(response.headers.get('Content-Type'));
console.log(response.headers.get('X-Request-Id'));

Common headers include Authorization, Content-Type, Accept, and Accept-Language. Browsers restrict certain request headers and may hide some response headers unless the server explicitly exposes them. Header restrictions cannot be removed by changing JavaScript code.

Error handling

There are several failure categories:

SituationDoes fetch reject?How to detect itRecommended handling
Offline or network failureUsually yescatch receives an errorShow a connectivity message and log safe diagnostics
Aborted requestYesInspect the abort error or signal stateTreat intentional cancellation separately from failure
Timeout implemented with abortYesTrack that your timeout caused the abortOffer retry or a slower-operation message
HTTP 4xx responseNoCheck response.ok or statusHandle validation, authentication, or not-found states
HTTP 5xx responseNoCheck statusShow a temporary server-error state; retry only when appropriate
Invalid JSON responseParsing rejectsresponse.json() throwsCheck content type and handle empty, HTML, or malformed bodies
CORS browser blockOften yes, with limited detailsDeveloper tools and server logsConfigure the API server; frontend code cannot reliably bypass CORS
async function readJson(response) {
  if (!response.ok) {
    const message = `HTTP ${response.status}`;
    throw new Error(message);
  }

  if (response.status === 204) return null;

  const contentType = response.headers.get('Content-Type') || '';
  if (!contentType.includes('application/json')) {
    throw new Error('The server returned an unexpected format');
  }

  try {
    return await response.json();
  } catch {
    throw new Error('The server returned malformed JSON');
  }
}

Log request identifiers, status codes, and safe technical context, but never log bearer tokens, passwords, cookies, or unnecessary personal data. User-facing messages should explain what the user can do next without exposing server internals.

Cancellation and time limits

AbortController cancels a fetch when its AbortSignal is passed as signal.

const controller = new AbortController();

const request = fetch('/api/search?q=books', {
  signal: controller.signal
});

controller.abort();

A timeout is commonly implemented by aborting after a delay. Use a distinct flag or error reason so the UI can distinguish a timeout from deliberate cancellation.

async function fetchWithTimeout(url, options = {}, milliseconds = 8000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), milliseconds);

  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

Search-as-you-type

let activeController;

async function search(query) {
  activeController?.abort();
  activeController = new AbortController();

  const params = new URLSearchParams({ q: query });
  try {
    const response = await fetch(`/api/search?${params}`, {
      signal: activeController.signal
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);

    const results = await response.json();
    renderResults(results);
  } catch (error) {
    if (error.name !== 'AbortError') {
      showErrorState('Search failed.');
    }
  }
}

Aborting the previous request prevents stale work. You can also assign each request an identifier and ignore results that do not match the latest query, because requests may otherwise complete out of order.

Credentials, authentication, and cookies

The credentials option controls whether cookies and related credentials accompany a request:

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

Bearer-token APIs commonly use an authorization header:

const response = await fetch('https://api.example.test/profile', {
  headers: {
    Accept: 'application/json',
    Authorization: `Bearer ${accessToken}`
  }
});

Never place private server secrets in browser JavaScript. Anything shipped to the browser can be inspected by the user. Protect sensitive operations on a server and use HTTPS.

For cookie-authenticated cross-origin requests, the client may need credentials: 'include'. The API must also allow the specific requesting origin, allow credentials in its CORS response, and issue cookies with compatible attributes such as SameSite and Secure. State-changing cookie-authenticated requests also require CSRF protection.

Cross-origin requests and CORS

The same-origin policy restricts scripts from freely reading resources from a different scheme, host, or port. CORS, or Cross-Origin Resource Sharing, is a server-controlled mechanism that grants narrowly defined exceptions.

For some cross-origin requests, the browser first sends a preflight request: an OPTIONS request asking whether the origin, method, and headers are allowed. The server responds with headers such as Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and, when appropriate, Access-Control-Allow-Credentials. The browser enforces the result.

  • mode: 'cors' requests server permission and exposes only permitted response data.
  • mode: 'same-origin' rejects cross-origin requests.
  • mode: 'no-cors' does not bypass CORS; it produces an opaque response that frontend code cannot normally inspect.

A CORS error must be fixed on the API server or through a server-side proxy under your control. It cannot be reliably bypassed with frontend Fetch options. For credentialed access, wildcard origins are not a valid replacement for a specific allowed origin.

Request and response body streams

response.body can expose a ReadableStream. A stream allows large data to be read in chunks rather than waiting for the complete body.

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

const reader = response.body.getReader();
const decoder = new TextDecoder();
let totalText = '';

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  totalText += decoder.decode(value, { stream: true });
}

totalText += decoder.decode();
processText(totalText);

Chunked reading is useful for large downloads and progressive processing. A reliable byte percentage requires a known Content-Length, which may be absent or transformed. Fetch does not provide universal upload-progress events; use XMLHttpRequest or a protocol-specific solution when upload progress is required.

Caching and request behavior

Browser caching can reduce latency and bandwidth for resources that do not change often. Use default for normal browser cache behavior, no-store when neither reading nor writing cached data is appropriate, reload to fetch from the network while updating the cache, no-cache to require revalidation, force-cache to prefer an existing cached response, and only-if-cached when a cached response is required under its browser restrictions.

Use fresh-data behavior for rapidly changing dashboards, security-sensitive decisions, or after a mutation when stale data would be harmful. Cache deliberately for static resources and repeatable reads.

Redirects are followed by default. Set redirect: 'error' when redirects should fail, or redirect: 'manual' when the application needs controlled handling. Other useful options include referrerPolicy for referrer privacy, integrity for expected-resource verification, keepalive for small shutdown-time requests, and priority where supported to express relative request urgency. Browser support and server behavior vary, so these options should not replace correctness checks.

Reusable API client patterns

A small wrapper centralizes a base URL, default headers, response parsing, and error behavior.

function createApiClient({ baseUrl, token }) {
  async function request(path, options = {}) {
    const response = await fetch(new URL(path, baseUrl), {
      ...options,
      headers: {
        Accept: 'application/json',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...options.headers
      }
    });

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

    if (response.status === 204) return null;
    return response.json();
  }

  return {
    listItems: () => request('/items'),
    createItem: (item) => request('/items', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item)
    }),
    updateItem: (id, changes) => request(`/items/${encodeURIComponent(id)}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(changes)
    }),
    deleteItem: (id) => request(`/items/${encodeURIComponent(id)}`, {
      method: 'DELETE'
    })
  };
}

Retries need a policy. Limited retries with exponential backoff can help transient failures for safe, idempotent operations such as many GET requests. Do not automatically retry non-idempotent POST operations unless the API supports idempotency keys or another duplicate-prevention mechanism. A request being technically repeatable does not guarantee that repeating it has safe application semantics.

Security and production considerations

  • Validate user input on the server and encode user-derived URL parameters with URLSearchParams or encodeURIComponent.
  • Use HTTPS for authentication, personal data, and other sensitive requests.
  • Do not log authorization headers, tokens, passwords, cookies, or unnecessary personal data.
  • Use CSRF tokens and appropriate cookie settings for cookie-authenticated state-changing requests.
  • Client-side validation improves usability but never replaces server-side validation and authorization.
  • Check the response format before parsing and handle 204 No Content separately.

Practical troubleshooting

  • A 404 or 500 reaches the success branch: Fetch resolved because an HTTP response arrived. Check response.ok and response.status before parsing.
  • response.json() throws: The body may be empty, malformed, HTML, or another format. Inspect status, Content-Type, and safe raw text.
  • A request body is malformed: JSON may not have been stringified, or the body and Content-Type may disagree.
  • A FormData upload fails: Remove the manually assigned multipart Content-Type so the browser can add its boundary.
  • A CORS error appears: Inspect developer tools and server headers. Configure CORS on the API server rather than trying to solve it in frontend code.
  • Cross-origin cookies are missing: Check credentials, cookie attributes, and credentialed CORS configuration together.
  • Old search results overwrite new ones: Abort the previous request or ignore responses whose query is no longer current.
  • The body cannot be read twice: Store the parsed value or call clone() before the first read.

Exam-relevant notes

  • fetch() rejects for network-level failures and aborts, not ordinary HTTP 4xx or 5xx responses.
  • Always check response.ok or response.status before treating a response as successful.
  • A response body is normally consumable once; use clone() when two readers are genuinely needed.
  • FormData uploads should not receive a manually assigned multipart Content-Type.
  • CORS is enforced by the browser and must be enabled by the server.
  • AbortController supports cancellation and is the usual building block for timeouts.
  • For a 204 response, do not call response.json() unless the API actually supplies a body.