Sending an HTTP request in JavaScript takes one line: fetch(url). The bugs live in the lines around it, where a 404 counts as success and a stalled server never times out.
This guide covers the requests you'll write every week: GET, POST, PUT, PATCH, DELETE, forms and file uploads. Then it fixes what breaks in production, which is error handling, timeouts, retries, CORS and concurrency.
Every snippet was run on Node 22 against a local test server, and the error messages in the troubleshooting section are copied from those runs. The one browser-only snippet (XHR upload progress) was syntax-checked.
How do you make an HTTP request in JavaScript?
HTTP requests in JavaScript are sent with the built-in fetch() function: pass a URL, await the Response, then read the body with response.json(). Check response.ok yourself, because fetch only rejects on network failures, not on a 404 or 500. It runs in every modern browser and in Node.js 18 and later.
The shortest version worth shipping:
const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const user = await response.json();
console.log(user.name); // "Leanne Graham"
Notice the two awaits. They exist because a response arrives in two stages, and that split explains most fetch surprises.
The first promise resolves as soon as the status line and headers land. At that point the body may still be streaming, so reading it needs its own await.
This is also why a 500 resolves without error: the server did answer. And it's why you can read a body only once, since it's a stream.
Prerequisites
- A modern browser or Node.js 18+, where
fetch()is a global. - Node.js 22.21+ or 24+ if you want dependency-free proxy support (covered in the Node section).
- A test API. The examples use JSONPlaceholder, which accepts fake writes and returns realistic JSON.
Top-level await works in ES modules and the browser console. In a CommonJS file, wrap the examples in an async function.
Step 1: Send a GET request with fetch()
Put the call in an async function so you can check the status before you touch the body.
async function getUsers() {
const response = await fetch('https://jsonplaceholder.typicode.com/users');
// Resolving only means the server answered. Check what it said.
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json(); // the caller awaits the body
}
const users = await getUsers();
console.log(users.length); // 10
response.ok is true for statuses 200 through 299. Skip the check and a 404 page gets handed to .json(), which throws a confusing SyntaxError somewhere else entirely.
Step 2: Add query parameters and headers
Don't glue query strings together with +. The URL object encodes spaces, ampersands and non-ASCII characters for you.
const token = 'YOUR_API_TOKEN';
const url = new URL('https://jsonplaceholder.typicode.com/posts');
url.searchParams.set('userId', '1');
url.searchParams.set('q', 'coffee & tea'); // sent as coffee+%26+tea
const response = await fetch(url, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
});
fetch() takes a URL object directly, so no toString() call is needed.
Browsers silently drop a short list of forbidden headers, including Cookie, Host and Content-Length. Node's fetch lets you set all of them, which matters once you move server-side.
Step 3: Send a POST request with a JSON body
A JSON POST needs three things: the method, a Content-Type header, and a stringified body.
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Hello', body: 'First post', userId: 1 }),
});
if (!response.ok) throw new Error(`POST failed: ${response.status}`);
const created = await response.json();
console.log(response.status, created.id); // 201 101
Pass a plain object without JSON.stringify and fetch sends the literal string [object Object]. Leave out the header and many servers parse the body as plain text and reject it.
Step 4: Update and delete with PUT, PATCH and DELETE
PUT replaces a whole resource and PATCH changes only the fields you send. DELETE removes it. The calls differ only in method and body, so one helper covers all three.
async function sendJson(url, method, data) {
const response = await fetch(url, {
method,
headers: data ? { 'Content-Type': 'application/json' } : undefined,
body: data ? JSON.stringify(data) : undefined,
});
if (!response.ok) throw new Error(`${method} ${url} -> ${response.status}`);
if (response.status === 204) return null; // No Content: nothing to parse
return response.json();
}
const post = 'https://jsonplaceholder.typicode.com/posts/1';
await sendJson(post, 'PUT', { id: 1, title: 'Replaced', body: '...', userId: 1 });
await sendJson(post, 'PATCH', { title: 'Only the title changed' });
await sendJson(post, 'DELETE');
Keep the 204 check. Plenty of APIs answer DELETE with 204 No Content, and calling .json() on that empty body throws Unexpected end of JSON input.
Step 5: Send form data and upload files
Classic forms and file uploads use different encodings. Pass a FormData or URLSearchParams object as the body and fetch picks the matching Content-Type.
// multipart/form-data: text fields plus files
const form = new FormData();
form.append('username', 'marius');
form.append('avatar', fileInput.files[0]); // File from <input type="file">
await fetch('/api/profile', { method: 'POST', body: form });
// application/x-www-form-urlencoded: classic login-style forms
await fetch('/api/login', {
method: 'POST',
body: new URLSearchParams({ user: 'marius', remember: '1' }),
});
Don't set Content-Type yourself for FormData. Fetch writes multipart/form-data; boundary=..., and a hand-typed header without that boundary breaks the upload on the server.
Step 6: Handle errors the way fetch reports them
Fetch fails in six distinct ways, and they surface in different places. Lumping them into one catch is how apps end up showing "Something went wrong" for everything.
| What happened | Promise | What you get | Worth retrying? |
|---|---|---|---|
| DNS failure, refused connection, offline | Rejects | TypeError ("Failed to fetch" in Chrome, "fetch failed" in Node) |
Yes |
| Blocked by CORS | Rejects | The same TypeError; details only in the console |
No, fix the server |
| 4xx or 5xx status | Resolves | response.ok === false |
Only 408, 429 and 5xx |
AbortSignal.timeout() fired |
Rejects | DOMException named TimeoutError |
Usually |
You called controller.abort() |
Rejects | DOMException named AbortError |
No |
| Body isn't valid JSON | .json() rejects |
SyntaxError |
No |
A small wrapper turns that table into something your code can branch on. It parses by Content-Type and throws an HttpError carrying the status and the server's error body.
class HttpError extends Error {
constructor(response, body) {
super(`HTTP ${response.status} ${response.statusText}`);
this.name = 'HttpError';
this.status = response.status;
this.body = body; // the server's error payload, often the useful part
}
}
async function request(url, options = {}) {
const response = await fetch(url, options);
const type = response.headers.get('content-type') ?? '';
const body = response.status === 204 ? null
: type.includes('application/json') ? await response.json()
: await response.text(); // HTML error pages stay readable
if (!response.ok) throw new HttpError(response, body);
return body;
}
Branching on the result then reads cleanly:
try {
const user = await request('https://jsonplaceholder.typicode.com/users/999');
} catch (error) {
if (error instanceof HttpError && error.status === 404) showEmptyState();
else if (error.name === 'TimeoutError') showRetryButton();
else if (error instanceof TypeError) showOfflineBanner();
else throw error;
}
Test for instanceof TypeError and skip matching the message text. Chrome says "Failed to fetch", Firefox says "NetworkError when attempting to fetch resource." and Safari says "Load failed", so matching on "fetch" misses Safari users.
Step 7: Add a timeout, because fetch has none
fetch() has no timeout option. A server that accepts the connection and then stalls can hold your request open for minutes, and your loading spinner with it.
AbortSignal.timeout() fixes that in one argument:
try {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
signal: AbortSignal.timeout(5000), // covers headers AND body
});
const posts = await response.json();
} catch (error) {
if (error.name === 'TimeoutError') {
console.error('Gave up after 5 seconds');
} else {
throw error;
}
}
Watch the error name. Older tutorials build timeouts from setTimeout plus controller.abort() and check for AbortError, but AbortSignal.timeout() throws TimeoutError. Copy the old check and your timeouts slip past the handler.
To support a Cancel button and a timeout on the same request, combine the signals:
const userCancel = new AbortController();
cancelButton.onclick = () => userCancel.abort();
const signal = AbortSignal.any([userCancel.signal, AbortSignal.timeout(10_000)]);
const response = await fetch('/api/report', { signal });
Whichever fires first wins, and error.name tells you which: AbortError for the button, TimeoutError for the clock.
Step 8: Cancel requests the user no longer needs
Search-as-you-type is the classic case. A slow response for "co" can land after the fast one for "coffee" and overwrite the right results with stale ones.
let inFlight;
async function search(term) {
inFlight?.abort(); // kill the previous keystroke's request
inFlight = new AbortController();
try {
const url = `/api/search?q=${encodeURIComponent(term)}`;
const response = await fetch(url, { signal: inFlight.signal });
return await response.json();
} catch (error) {
if (error.name === 'AbortError') return null; // superseded, not a failure
throw error;
}
}
Fired three times in a row in my test, the first two calls returned null and only the last one produced results. Treat null as "ignore this", never as an error to show the user.
Step 9: Run requests in parallel without flooding the server
Promise.all fails fast: one rejection and you lose every other result. For batches where partial success is fine, Promise.allSettled reports each outcome separately.
const ids = [1, 2, 3, 4, 5];
const results = await Promise.allSettled(
ids.map((id) => request(`https://jsonplaceholder.typicode.com/users/${id}`))
);
const users = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
const failed = results.filter((r) => r.status === 'rejected').length;
Both start every request at once. With 500 URLs, that's 500 simultaneous requests, and most APIs answer with a wall of HTTP 429 "Too Many Requests" errors. A small worker pool caps it:
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++; // claimed synchronously, so no two workers share an index
results[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
const pages = await mapLimit(urls, 4, (url) => request(url));
With 20 URLs and a limit of 4, my test run never had more than 4 requests open, and results came back in input order. Browsers already cap HTTP/1.1 at six connections per host, so the limit matters most in Node.
Retry failed requests without making an outage worse
Retries turn a network blip into a success. Done carelessly, they turn a struggling server into a dead one. Four rules keep them safe:
- Retry network errors and statuses 408, 429, 500, 502, 503 and 504. A 400, 401, 403 or 404 fails the same way on the second try.
- Honor
Retry-Afterwhen the server sends it. The value is either seconds or an HTTP date. - Back off exponentially with random jitter, so a thousand clients don't retry in the same millisecond.
- Don't auto-retry a POST unless the API supports idempotency keys. You might create the order twice.
The delay calculation reads Retry-After first and falls back to "full jitter" backoff:
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function retryDelay(response, attempt) {
const header = response?.headers.get('retry-after');
if (header) {
const seconds = Number(header);
return Number.isNaN(seconds)
? Math.max(0, Date.parse(header) - Date.now()) // HTTP-date form
: seconds * 1000;
}
return Math.random() * Math.min(30_000, 500 * 2 ** attempt); // full jitter
}
The loop retries only what's worth retrying and hands back the final Response so your normal status handling still runs:
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let attempt = 0; ; attempt++) {
let response;
try {
response = await fetch(url, options);
if (response.ok || !RETRYABLE.has(response.status)) return response;
} catch (error) {
if (error.name !== 'TypeError') throw error; // timeouts and aborts: stop
}
if (attempt >= maxRetries) {
if (response) return response;
throw new Error(`Network failure after ${maxRetries + 1} attempts`);
}
await sleep(retryDelay(response, attempt));
}
}
Against a test endpoint that returned 503 twice, this succeeded on the third call. A 404 came back after one hit, and a 429 with Retry-After: 1 waited a full second before retrying.
If you're carrying an older retry helper that reads error.response inside the catch, test it against a 503. Fetch errors have no response property, so that check quietly turns retries off.
Fix CORS errors ("blocked by CORS policy")
CORS is the browser enforcing rules set by the server you're calling. The same request works in curl, Postman and Node, because none of them enforce CORS.
The target server has to opt in by sending Access-Control-Allow-Origin for your origin. Sending JSON or a custom header also triggers a preflight OPTIONS request, which the server must answer too. The MDN CORS guide lists exactly which requests preflight.
Skip two popular non-fixes. mode: 'no-cors' returns an opaque response with status 0 and no readable body. Public CORS proxies route your users' requests, and sometimes their tokens, through a stranger's server.
If you control the API, add the header there. If you don't, make the request from your own backend, where CORS doesn't apply. A minimal same-origin forwarder in Node:
import http from 'node:http';
const UPSTREAM = 'https://api.example.com'; // the only host this will call
http.createServer(async (req, res) => {
if (req.method !== 'GET') return res.writeHead(405).end();
try {
const upstream = await fetch(new URL(req.url, UPSTREAM), {
signal: AbortSignal.timeout(10_000),
});
res.writeHead(upstream.status, {
'Content-Type': upstream.headers.get('content-type') ?? 'text/plain',
'Access-Control-Allow-Origin': 'http://localhost:5173', // your front end
});
res.end(Buffer.from(await upstream.arrayBuffer()));
} catch {
res.writeHead(502).end();
}
}).listen(8787);
It forwards to one fixed host and allows GET only. A forwarder that accepts any URL becomes a free open proxy for whoever finds it.
Making HTTP requests in JavaScript on Node.js
Node 18 and later ship the same fetch(), built on the undici library. Everything above runs unchanged, with a few server-side differences:
- There's no CORS and no forbidden-header list, so you can set
CookieandUser-Agentfreely. - The default
User-Agentis the bare stringnode, which some sites block on sight. Set a real one if you're fetching web pages. - There's no cookie jar. Read cookies with
response.headers.getSetCookie()and send them back yourself. - On a failed request,
error.causeoften names the real problem, such asENOTFOUNDfor a bad hostname.
Send fetch through a proxy
Node 22.21+ and 24+ can route fetch() through a proxy with no dependencies. Set the standard variables and opt in, as described in Node's network configuration docs:
# Route fetch() through a proxy with no packages installed
export HTTP_PROXY=http://user:[email protected]:8080
export HTTPS_PROXY=http://user:[email protected]:8080
export NO_PROXY=localhost,127.0.0.1
NODE_USE_ENV_PROXY=1 node scraper.js
For a different proxy per request, install undici and pass a dispatcher. Import fetch from the same package so the two versions match:
import { fetch, ProxyAgent } from 'undici';
const proxy = new ProxyAgent(process.env.PROXY_URL); // http://user:pass@host:port
const response = await fetch('https://httpbin.org/ip', { dispatcher: proxy });
console.log(await response.json()); // prints the proxy's exit IP, not yours
That's where a rotating gateway plugs in, whether it's a Roundproxies residential endpoint or your own proxy pool: the gateway URL goes in PROXY_URL and the rest of your code doesn't change.
For the other ways to send requests server-side, from node:https to Got and raw sockets, see 15 ways to make HTTP requests in Node.js. If you're fetching pages to extract data, the JavaScript web scraping guide picks up where this one stops.
Fetch vs Axios vs XMLHttpRequest
fetch() |
Axios | XMLHttpRequest |
|
|---|---|---|---|
| Install | Built in | npm package | Built in |
| Throws on 4xx/5xx | No | Yes | No |
| JSON parsing | response.json() |
Automatic | responseType = 'json' |
| Timeout | AbortSignal.timeout() |
timeout option |
xhr.timeout |
| Interceptors | Write a wrapper | Built in | No |
| Upload progress | No | Yes (onUploadProgress) |
Yes (xhr.upload) |
| Style | Promises | Promises | Events and callbacks |
My default is fetch() plus the 20-line request() wrapper from Step 6. That covers what most people install Axios for.
Axios earns its dependency in apps with dozens of endpoints that share auth headers, base URLs and interceptors. The setup is short:
import axios from 'axios';
const api = axios.create({ baseURL: 'https://jsonplaceholder.typicode.com', timeout: 5000 });
try {
const { data } = await api.get('/users', { params: { _limit: 5 } });
console.log(data);
} catch (error) {
console.error(error.response?.status ?? error.code); // status, or ECONNABORTED etc.
}
It's also a common pairing for scraping; the Axios and Cheerio tutorial shows that combination end to end.
XMLHttpRequest has one reason left to exist in new code: upload progress. Fetch can't report bytes sent, so a real progress bar still needs XHR. Wrap it in a promise once and await it like everything else:
function uploadWithProgress(url, file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => (xhr.status < 300 ? resolve(xhr.response) : reject(new Error(`HTTP ${xhr.status}`)));
xhr.onerror = () => reject(new TypeError('Network error'));
const body = new FormData();
body.append('file', file);
xhr.send(body);
});
}
Copy a working request straight from DevTools
When a site's own front end already makes the request you want, don't rebuild it header by header. Your browser can write the fetch call for you.
- Open DevTools (F12), switch to the Network tab and filter by Fetch/XHR.
- Trigger the action on the page and click the request that returns the data.
- Right-click it and choose Copy > Copy as fetch, or Copy as fetch (Node.js) for server-side code. Firefox has the same option under Copy Value.
The pasted code carries every header the browser sent. Delete them one at a time and rerun; whatever you can't remove without breaking the request is what the server checks.
The Node.js variant includes your Cookie header, which is your logged-in session. Treat it like a password and keep it out of Git.
Troubleshooting
"TypeError: Failed to fetch" (or "fetch failed" in Node)
Why: No response came back. Common causes are a wrong hostname, a refused connection, a CORS block, an ad blocker, or an HTTPS page calling an http:// API (mixed content).
Fix: Check the console first; CORS and mixed-content blocks print their own message there. In Node, log error.cause, which names the underlying failure.
"Access to fetch at '...' from origin '...' has been blocked by CORS policy"
Why: The server didn't send Access-Control-Allow-Origin for your origin, or it failed the preflight OPTIONS request.
Fix: Add the header on the server, or move the call to your backend. See the CORS section.
"SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON"
Why: The server returned HTML, usually an error page, a login redirect or a bot challenge, and you called .json() on it.
Fix: Check response.ok and the Content-Type header before parsing. When debugging, log await response.text() to see what came back.
"TypeError: Body is unusable: Body has already been read"
Chrome phrases it as "Failed to execute 'json' on 'Response': body stream already read".
Why: You read the body twice, often once for logging and once for parsing.
Fix: Read it once into a variable, or call response.clone() before the first read.
"Unexpected end of JSON input"
Why: The body was empty, typically a 204 No Content or a HEAD request.
Fix: Return early on 204, as the helpers in Steps 4 and 6 do.
FAQ
Is fetch better than Axios?
For most projects, yes. Fetch is built in, works in browsers and Node, and a short wrapper adds the error handling Axios gives you. Axios pays off when many endpoints need shared interceptors or when you want upload progress without touching XHR.
Can you make HTTP requests in JavaScript without a library?
Yes. fetch() ships in every modern browser and in Node.js 18+, and it handles GET, POST, PUT, PATCH, DELETE, forms and file uploads with no install.
How do I make a synchronous HTTP request in JavaScript?
You can't with fetch, and synchronous XMLHttpRequest on the main thread is deprecated because it freezes the page. Use await inside an async function; the code reads top to bottom like synchronous code without blocking.
Why does my request work in Postman but not in the browser?
The browser enforces CORS and Postman doesn't. If the API doesn't send Access-Control-Allow-Origin for your site's origin, the browser blocks the response even though the server sent it.
Wrapping up
The mental model to keep: fetch() resolves when the server answers, and everything else is your job. Check response.ok, add a timeout, and retry only what's worth retrying.
Start by dropping the request() wrapper and AbortSignal.timeout() into your project, since those two fix the most common production bugs. If you also work in Python, the same patterns carry over to making HTTP requests in Python.