> ## Content Index
> Fetch the complete content index at: https://roundproxies.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to make HTTP requests in JavaScript (2026 guide)
- URL: https://roundproxies.com/blog/requests-javascript/
- Published: 2025-10-06T22:17:00.000Z
- Updated: 2026-09-24T13:56:28.000Z
- Description: Make HTTP requests in JavaScript with fetch(): GET, POST, timeouts, retries and CORS. Tested code.
- Author: Marius Bernard
- Tags: Knowledgebase, #dated-68e43c554fa498a2c6ee1f70

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:

```javascript
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 `await`s. 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](https://jsonplaceholder.typicode.com/), 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.

```javascript
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.

```javascript
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.

```javascript
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.

```javascript
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`.

```javascript
// 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.

```javascript
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:

```javascript
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:

```javascript
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:

```javascript
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.

```javascript
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.

```javascript
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](https://roundproxies.com/blog/http-error-429/). A small worker pool caps it:

```javascript
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-After` when 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:

```javascript
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:

```javascript
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](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) 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:

```javascript
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 `Cookie` and `User-Agent` freely.
- The default `User-Agent` is the bare string `node`, 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.cause` often names the real problem, such as `ENOTFOUND` for 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](https://nodejs.org/en/learn/http/enterprise-network-configuration):

```bash
# Route fetch() through a proxy with no packages installed
export HTTP_PROXY=http://user:pass@proxy.example.com:8080
export HTTPS_PROXY=http://user:pass@proxy.example.com: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:

```javascript
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](https://roundproxies.com/blog/http-requests-nodejs/). If you're fetching pages to extract data, the [JavaScript web scraping guide](https://roundproxies.com/blog/how-to-web-scrape-with-javascript/) 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:

```javascript
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](https://roundproxies.com/blog/web-scraping-with-axios-and-cheerio/) 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:

```javascript
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.

1. Open DevTools (F12), switch to the Network tab and filter by **Fetch/XHR**.
2. Trigger the action on the page and click the request that returns the data.
3. 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](https://claude.ai/chat/d777e410-c9b6-4d36-8efd-a55545c1f038#fix-cors-errors-blocked-by-cors-policy).

### "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.