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.
| Member | What it provides | Typical use | Important caveat |
|---|---|---|---|
ok | Boolean indicating a status from 200 through 299 | Check for a successful HTTP response | It is false for 3xx, 4xx, and 5xx statuses |
status | Numeric HTTP status code | Handle 201, 204, 401, 404, or 500 specifically | It does not itself throw an exception |
statusText | HTTP reason text, such as “OK” | Diagnostics | Do not depend on it for application logic |
headers | A Headers collection | Read content type, pagination, or caching metadata | Some headers are hidden by browser security rules |
url | Final response URL | Inspect redirects or diagnostics | It may differ from the requested URL |
redirected | Whether a redirect was followed | Detect redirect behavior | Use redirect options when policy matters |
type | Response category, such as basic, cors, opaque, or error | Understand browser handling | An opaque response exposes very little information |
json() | Parses the body as JSON | JSON APIs | Rejects if the body is empty or invalid JSON |
text() | Reads the body as text | Plain text, HTML, or safe diagnostic output | The body is consumed |
blob() | Reads a Blob | Images, downloads, and other files | The body is consumed |
arrayBuffer() | Reads binary bytes | Binary protocols or custom processing | Can use substantial memory for large data |
formData() | Parses a form-data response | Responses encoded as form data | The response format must actually be form data |
body | A readable response stream | Process large responses incrementally | It 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.
| Option | Purpose | Typical values | Usage notes |
|---|---|---|---|
method | Selects the HTTP operation | GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS | GET and HEAD normally have no body |
headers | Supplies request metadata | Object or Headers | Some headers are controlled by the browser |
body | Sends request data | String, FormData, URLSearchParams, Blob, bytes | Do not send a body with GET or HEAD |
credentials | Controls cookies and credentials | same-origin, include, omit | Cross-origin cookies also require server permission |
mode | Controls cross-origin behavior | cors, same-origin, no-cors | no-cors does not bypass CORS or expose a normal response |
cache | Influences the HTTP cache | default, no-store, reload, no-cache, force-cache, only-if-cached | Use fresh-data modes deliberately |
redirect | Controls redirect handling | follow, error, manual | Redirect policy affects authentication and URL assumptions |
signal | Connects an AbortSignal | controller.signal | Aborting rejects the fetch promise |
referrerPolicy | Controls referrer information | no-referrer, strict-origin-when-cross-origin | Choose a policy appropriate for privacy |
integrity | Verifies a known response hash | Subresource integrity hash | Useful when fetching a resource with a trusted expected digest |
keepalive | Allows limited requests to continue during page shutdown | true | Request 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 type | Body construction | Content-Type handling | Typical use |
|---|---|---|---|
| JSON | JSON.stringify(value) | Set application/json | Structured API payloads |
| URL-encoded data | new URLSearchParams(values) | Browser can set the appropriate type | Simple forms and query parameters |
| Multipart form data | new FormData(form) | Let the browser set it | Forms with files |
| Plain text | A string | Set text/plain when required | Text endpoints or webhooks |
| Binary data | Blob, ArrayBuffer, or typed bytes | Use the server's expected binary type | Binary 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:
| Situation | Does fetch reject? | How to detect it | Recommended handling |
|---|---|---|---|
| Offline or network failure | Usually yes | catch receives an error | Show a connectivity message and log safe diagnostics |
| Aborted request | Yes | Inspect the abort error or signal state | Treat intentional cancellation separately from failure |
| Timeout implemented with abort | Yes | Track that your timeout caused the abort | Offer retry or a slower-operation message |
| HTTP 4xx response | No | Check response.ok or status | Handle validation, authentication, or not-found states |
| HTTP 5xx response | No | Check status | Show a temporary server-error state; retry only when appropriate |
| Invalid JSON response | Parsing rejects | response.json() throws | Check content type and handle empty, HTML, or malformed bodies |
| CORS browser block | Often yes, with limited details | Developer tools and server logs | Configure 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
URLSearchParamsorencodeURIComponent. - 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 Contentseparately.
Practical troubleshooting
- A 404 or 500 reaches the success branch: Fetch resolved because an HTTP response arrived. Check
response.okandresponse.statusbefore 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-Typemay disagree. - A FormData upload fails: Remove the manually assigned multipart
Content-Typeso 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.okorresponse.statusbefore treating a response as successful. - A response body is normally consumable once; use
clone()when two readers are genuinely needed. FormDatauploads should not receive a manually assigned multipartContent-Type.- CORS is enforced by the browser and must be enabled by the server.
AbortControllersupports 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.