Fetching data in JavaScript is easy for one record and surprisingly fiddly for twenty. The question that decides your code is how many requests are allowed to be in flight at once: one after another, a fixed number side by side, all at once, or a single request that asks for everything.
Every example on this page runs against a small mock server that you start on your own machine, so nothing here depends on a third-party site staying up. Save the first block as server.mjs, run it with node server.mjs, and leave it running while you work through the rest.
What does a fetch request actually give you?
fetch returns a promise that resolves with a response object, not with the data you asked for. Reading the body is a second step, and the body of a JSON response is itself a promise. There is one trap to know before anything else: a request that comes back with a server error still resolves. Only a network-level failure rejects the promise, so a response with a status of 500 arrives as a perfectly successful promise.
import { createServer } from 'node:http';
const items = [
{ id: 1, name: 'Notebook', price: 4.5 },
{ id: 2, name: 'Pen', price: 1.2 },
{ id: 3, name: 'Backpack', price: 32 },
{ id: 4, name: 'Charger', price: 18.75 },
];
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const send = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
const ids = url.searchParams.get('ids');
await wait(ids ? 60 : 120);
if (ids) return send(res, 200, items.filter((i) => ids.split(',').includes(String(i.id))));
const id = Number(url.pathname.split('/').pop());
if (id === 4) return send(res, 500, { error: 'item 4 is unavailable' });
const item = items.find((i) => i.id === id);
if (!item) return send(res, 404, { error: 'no item with id ' + id });
send(res, 200, item);
}).listen(8787, '127.0.0.1', () => console.log('mock api on http://127.0.0.1:8787'));
The server waits 120 milliseconds before answering a single-item request, which gives the timings below something real to measure. Item 4 always answers with a server error, so you can see what a partial failure looks like.
const BASE = 'http://127.0.0.1:8787';
async function loadOne(id) {
const response = await fetch(BASE + '/api/items/' + id);
if (!response.ok) {
throw new Error('request failed with status ' + response.status);
}
return response.json();
}
loadOne(1).then((item) => console.log(item.name, item.price));
The output is:
Notebook 4.5
The response.ok check is not optional decoration. Without it, a request for item 4 would hand you the error body as if it were the item you asked for, and the mistake would surface much later.
How do you fetch several records one after another?
A for loop with an await inside it sends one request, waits for the answer, then sends the next. Nothing overlaps. This is the right choice when the second request needs something the first one returned, and the wrong choice when the records are unrelated.
const BASE = 'http://127.0.0.1:8787';
async function warmUp() {
await fetch(BASE + '/api/items/1').then((response) => response.json());
}
async function loadSequentially(ids) {
const started = Date.now();
const items = [];
for (const id of ids) {
const response = await fetch(BASE + '/api/items/' + id);
items.push(await response.json());
}
console.log('sequential took', Date.now() - started, 'ms');
return items;
}
warmUp()
.then(() => loadSequentially([1, 2, 3]))
.then((items) => console.log(items.map((i) => i.name).join(', ')));
The output from one run:
sequential took 414 ms
Notebook, Pen, Backpack
Three requests at roughly 120 milliseconds each add up to more than 400 milliseconds. The warmUp helper is not part of the strategy; it only pays the one-off cost of opening the first connection so the measurement shows the requests themselves.
How do you fetch several records at the same time?
const BASE = 'http://127.0.0.1:8787';
async function warmUp() {
await fetch(BASE + '/api/items/1').then((response) => response.json());
}
async function loadInParallel(ids) {
const started = Date.now();
const responses = await Promise.all(ids.map((id) => fetch(BASE + '/api/items/' + id)));
const items = await Promise.all(responses.map((response) => response.json()));
console.log('parallel took', Date.now() - started, 'ms');
return items;
}
warmUp()
.then(() => loadInParallel([1, 2, 3]))
.then((items) => console.log(items.map((i) => i.name).join(', ')));
The output for the same three records:
parallel took 156 ms
Notebook, Pen, Backpack
Two details are easy to miss. The results keep the order of the ids you passed, so the names still read Notebook, Pen, Backpack even though two responses landed before the first one did. And the body is parsed in a second pass, because the response objects arrive first and the JSON is read afterwards.
How do you keep the number of open requests under control?
Firing a thousand requests at once is its own kind of failure. Browsers queue requests per origin, servers rate-limit by client, and the machine running your code pays for every open socket. The middle ground is a fixed number of workers pulling from a shared list of ids.
const BASE = 'http://127.0.0.1:8787';
async function warmUp() {
await fetch(BASE + '/api/items/1').then((response) => response.json());
}
async function loadWithLimit(ids, limit) {
const results = new Array(ids.length);
let cursor = 0;
const worker = async () => {
while (cursor < ids.length) {
const index = cursor;
cursor += 1;
const response = await fetch(BASE + '/api/items/' + ids[index]);
results[index] = await response.json();
}
};
await Promise.all(Array.from({ length: limit }, () => worker()));
return results;
}
warmUp()
.then(() => {
const started = Date.now();
return loadWithLimit([1, 2, 3], 2).then((items) => ({ items, elapsed: Date.now() - started }));
})
.then(({ items, elapsed }) => {
console.log('two at a time took', elapsed, 'ms');
console.log(items.map((i) => i.name).join(', '));
});
The output shows two rounds of work instead of three:
two at a time took 286 ms
Notebook, Pen, Backpack
The shared cursor is what keeps the workers busy without overlapping on the same id, and writing each result into its own index keeps the order stable no matter which worker finishes first. Change the limit to 1 and you get the sequential behaviour back; change it to 3 and you get the parallel version.
What happens when one request in a batch fails?
Promise.all rejects as soon as any response arrives with a failure, and the results of the others are lost even though they succeeded. When each record is worth keeping on its own, Promise.allSettled reports every outcome separately.
const BASE = 'http://127.0.0.1:8787';
async function loadEach(id) {
const response = await fetch(BASE + '/api/items/' + id);
if (!response.ok) throw new Error('status ' + response.status);
return response.json();
}
async function main() {
const results = await Promise.allSettled([1, 2, 4].map((id) => loadEach(id)));
for (const result of results) {
console.log(result.status === 'fulfilled' ? 'ok: ' + result.value.name : 'failed: ' + result.reason.message);
}
}
main();
The output keeps the two good records and reports the failing one:
ok: Notebook
ok: Pen
failed: status 500
Notice that the failure had to be turned into a rejection by hand, because fetch resolves on a server error. Checking response.ok inside the helper is what makes the settled results meaningful.
When should you ask the server for everything in one call?
If the server can return many records in one response, that beats any number of client-side tricks. One request means one round trip, one status code to check, and no ordering work on your side. The mock server exposes such a route for the same three records.
const BASE = 'http://127.0.0.1:8787';
async function warmUp() {
await fetch(BASE + '/api/items/1').then((response) => response.json());
}
async function loadBatch(ids) {
const started = Date.now();
const response = await fetch(BASE + '/api/items?ids=' + ids.join(','));
const items = await response.json();
console.log('one batch request took', Date.now() - started, 'ms');
return items;
}
warmUp()
.then(() => loadBatch([1, 2, 3]))
.then((items) => console.log(items.map((i) => i.name).join(', ')));
The output:
one batch request took 83 ms
Notebook, Pen, Backpack
Here is how the four approaches compared while fetching the same three records from the mock server:
| Approach | Requests in flight | Observed time |
|---|---|---|
| One request for all three | 1 | 83 ms |
| All three at once | 3 | 156 ms |
| Two at a time | 2 | 286 ms |
| One after another | 1 | 414 ms |
The numbers move with the machine and the network, but the order between them does not. More requests in flight means less waiting, up to the point where the server or the network becomes the bottleneck.
Which fetching strategy should you use?
- Ask for a batch whenever the server supports it — one round trip beats many small ones.
- Send unrelated requests together with
Promise.all, and keep the ids in the array you passed in so the order stays predictable. - Cap the number of open requests with a small worker pool when the list is long or the server is fragile.
- Keep requests sequential when each one depends on the answer to the previous one.
- Check
response.okon every request, because a server error resolves rather than rejects. - Use
Promise.allSettledwhen a single failed record should not discard the ones that worked.
Try it yourself: Start the mock server, then fetch the same three records four ways: sequentially, all at once, two at a time, and through the batch route. Print the elapsed time for each and line the numbers up. Next, change the limit in the worker version to 1 and to 3 and watch the timings move to the two extremes. Finally, ask for item 4 alongside items 1 and 2 with Promise.all and then with Promise.allSettled, and note how the first version loses the two good records while the second keeps them.