JavaScript
fetch API
asynchronous programming
loops
web development

How to run fetch in a loop?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Running fetch in a loop is common when you are loading paginated data, calling multiple endpoints, or processing a batch of IDs. The key design question is whether the requests must happen one after another or whether they can run in parallel, because the control flow and performance characteristics are very different.

Sequential fetch with for...of

If each request depends on the previous one, or if you want very simple control flow, use a normal loop with await.

javascript
1const urls = [
2  "https://api.example.com/page/1",
3  "https://api.example.com/page/2",
4  "https://api.example.com/page/3"
5];
6
7async function fetchSequentially() {
8  const results = [];
9
10  for (const url of urls) {
11    const response = await fetch(url);
12    if (!response.ok) {
13      throw new Error(`Request failed: ${response.status}`);
14    }
15
16    results.push(await response.json());
17  }
18
19  return results;
20}

This is often the right answer for pagination, retry-heavy workflows, or any case where each step depends on the last response. It is also easier to debug because the order is deterministic.

Parallel Requests with Promise.all

If the requests are independent, you can launch them all at once and wait for all of them to finish.

javascript
1async function fetchInParallel(urls) {
2  const promises = urls.map(async (url) => {
3    const response = await fetch(url);
4    if (!response.ok) {
5      throw new Error(`Request failed: ${response.status}`);
6    }
7
8    return response.json();
9  });
10
11  return Promise.all(promises);
12}

This usually reduces total time to roughly the slowest single request rather than the sum of all request times. The tradeoff is that one failure rejects the whole Promise.all result unless you deliberately handle each request separately.

Avoid forEach with async

One of the most common mistakes is writing urls.forEach(async (url) => ...) and expecting the outer code to wait. forEach does not understand await the way a for...of loop does.

Bad pattern:

javascript
1urls.forEach(async (url) => {
2  const response = await fetch(url);
3  console.log(await response.json());
4});

Preferred pattern:

javascript
1for (const url of urls) {
2  const response = await fetch(url);
3  console.log(await response.json());
4}

If you want concurrency, use map plus Promise.all, not forEach.

Concurrency-Limited Loops

Launching hundreds of requests at once can trigger rate limits or overwhelm the server. A worker-pool pattern lets you keep concurrency under control.

javascript
1async function fetchWithConcurrency(urls, limit = 5) {
2  const results = new Array(urls.length);
3  let index = 0;
4
5  async function worker() {
6    while (true) {
7      const current = index++;
8      if (current >= urls.length) {
9        return;
10      }
11
12      const response = await fetch(urls[current]);
13      if (!response.ok) {
14        throw new Error(`Failed: ${urls[current]} ${response.status}`);
15      }
16
17      results[current] = await response.json();
18    }
19  }
20
21  const workers = Array.from({ length: limit }, () => worker());
22  await Promise.all(workers);
23  return results;
24}

This is a good middle ground when requests are independent but the API cannot handle unlimited parallel traffic.

Looping Through Pagination

A common real-world case is fetching pages until the API says there are no more.

javascript
1async function fetchAllPages(baseUrl) {
2  const items = [];
3  let page = 1;
4
5  while (true) {
6    const response = await fetch(`${baseUrl}?page=${page}`);
7    if (!response.ok) {
8      throw new Error(`Page ${page} failed`);
9    }
10
11    const data = await response.json();
12    items.push(...data.items);
13
14    if (!data.nextPage) {
15      break;
16    }
17
18    page = data.nextPage;
19  }
20
21  return items;
22}

This pattern is clearer than recursion for most API clients and keeps pagination state explicit.

Timeouts, Retries, and Cancellation

Network loops need failure policy, not just syntax. A timeout wrapper prevents a single slow request from hanging the whole batch.

javascript
1function fetchWithTimeout(url, ms = 5000) {
2  const controller = new AbortController();
3  const timer = setTimeout(() => controller.abort(), ms);
4
5  return fetch(url, { signal: controller.signal })
6    .finally(() => clearTimeout(timer));
7}

For retries, wrap the request in a helper:

javascript
1async function fetchWithRetry(url, retries = 2) {
2  for (let attempt = 0; attempt <= retries; attempt += 1) {
3    try {
4      const response = await fetchWithTimeout(url, 5000);
5      if (!response.ok) {
6        throw new Error(`HTTP ${response.status}`);
7      }
8
9      return await response.json();
10    } catch (error) {
11      if (attempt === retries) {
12        throw error;
13      }
14
15      await new Promise((resolve) => setTimeout(resolve, 200 * (attempt + 1)));
16    }
17  }
18}

These helpers make loops much more reliable when networks are imperfect.

Common Pitfalls

The most common mistake is using forEach with async callbacks and expecting sequential await behavior. Another is firing too many requests in parallel and hitting throttling or rate limits. Developers also forget to check response.ok, which means error pages are treated as normal responses. Finally, network code without timeouts or retries tends to fail in production even when it looked fine during local testing.

Summary

  • Use for...of with await for sequential request loops.
  • Use Promise.all when requests are independent and can run in parallel.
  • Limit concurrency for large batches or rate-limited APIs.
  • Use explicit pagination loops for multi-page APIs.
  • Add timeout, retry, and response-status handling to make the loop reliable.

Course illustration
Course illustration

All Rights Reserved.