DEV Community

Recca Tsai
Recca Tsai

Posted on • Originally published at recca0120.github.io

ky + p-limit: Concurrency Control With Built-in Retry for Batch Requests

Originally published at recca0120.github.io

Batch 100 API calls with Promise.all and the server returns 429.
Add retry and transient network errors self-heal — but there's still no cap on how many run at once.
ky handles per-request reliability. p-limit controls overall throughput. Both together is the complete solution.

Two Problems, Two Tools

What ky solves: per-request reliability

  • Automatic retry with exponential backoff
  • Request timeout
  • Auth header injection via hooks
  • Consistent error handling

What p-limit solves: global concurrency control

  • At most N requests running simultaneously
  • The rest wait in a queue
  • Each slot opens as requests complete

The two problems are independent. Combining them handles both.

Installation

npm install ky p-limit
Enter fullscreen mode Exit fullscreen mode

Basic Usage

import ky from 'ky';
import pLimit from 'p-limit';

interface User {
  id: number;
  name: string;
  email: string;
}

// ky instance: retry, timeout, auth configured once
const api = ky.create({
  prefixUrl: 'https://api.example.com/v1',
  timeout: 10_000,
  retry: {
    limit: 3,
    statusCodes: [408, 429, 500, 502, 503, 504],
    backoffLimit: 3000,
  },
});

// p-limit: at most 5 concurrent requests
const limit = pLimit(5);

async function fetchUser(id: number): Promise<User> {
  return api.get(`users/${id}`).json<User>();
}

async function fetchAllUsers(ids: number[]): Promise<User[]> {
  return Promise.all(
    ids.map(id => limit(() => fetchUser(id)))
  );
}

const userIds = Array.from({ length: 100 }, (_, i) => i + 1);
const users = await fetchAllUsers(userIds);
Enter fullscreen mode Exit fullscreen mode

p-limit controls "how many run at once" at the outer level. ky handles "what to do when this request fails" at the inner level. Clear separation of concerns.

Full Instance With Auth Hook

import ky, { HTTPError } from 'ky';
import pLimit from 'p-limit';

const api = ky.create({
  prefixUrl: 'https://api.example.com/v1',
  timeout: 15_000,
  retry: {
    limit: 3,
    statusCodes: [408, 429, 500, 502, 503, 504],
    backoffLimit: 3000,
  },
  hooks: {
    beforeRequest: [
      (request) => {
        const token = localStorage.getItem('authToken');
        if (token) request.headers.set('Authorization', `Bearer ${token}`);
      },
    ],
    beforeRetry: [
      ({ request, error, retryCount }) => {
        console.warn(`Retry #${retryCount}: ${request.url}${error.message}`);
      },
    ],
    beforeError: [
      async (error) => {
        if (error instanceof HTTPError) {
          const body = await error.response.clone().json().catch(() => ({}));
          error.message = body.message ?? `HTTP ${error.response.status}`;
        }
        return error;
      },
    ],
  },
});
Enter fullscreen mode Exit fullscreen mode

Every request automatically carries the auth token, retries on failure with logging, and surfaces the server's error message when it ultimately fails.

Don't Fail Fast: Promise.allSettled

One failed request shouldn't abort the whole batch:

async function fetchAllUsersSafe(ids: number[]) {
  const limit = pLimit(5);

  const results = await Promise.allSettled(
    ids.map(id =>
      limit(() => api.get(`users/${id}`).json<User>())
    )
  );

  const succeeded: User[] = [];
  const failed: Array<{ id: number; error: Error }> = [];

  results.forEach((result, index) => {
    if (result.status === 'fulfilled') {
      succeeded.push(result.value);
    } else {
      failed.push({ id: ids[index], error: result.reason });
    }
  });

  return { succeeded, failed };
}

const { succeeded, failed } = await fetchAllUsersSafe(userIds);
console.log(`Succeeded: ${succeeded.length}, Failed: ${failed.length}`);
if (failed.length > 0) {
  console.error('Failed IDs:', failed.map(f => f.id));
}
Enter fullscreen mode Exit fullscreen mode

Promise.allSettled waits for everything regardless of outcome. Promise.all cancels on the first failure — use that when you need all-or-nothing.

Monitor Progress

const limit = pLimit(5);

const interval = setInterval(() => {
  console.log(`Active: ${limit.activeCount}, Pending: ${limit.pendingCount}`);
}, 1000);

const users = await Promise.all(
  userIds.map(id => limit(() => api.get(`users/${id}`).json<User>()))
);

clearInterval(interval);
Enter fullscreen mode Exit fullscreen mode

Dynamic Concurrency

Back off when the server starts rate-limiting, then recover:

const limit = pLimit(10);

// 429s increasing — slow down
limit.concurrency = 3;

// Back to normal
limit.concurrency = 10;
Enter fullscreen mode Exit fullscreen mode

Cancel Queued Requests

In a React component, cancel pending requests on unmount:

// rejectOnClear: true rejects cancelled tasks instead of silently dropping them
const limit = pLimit({ concurrency: 5, rejectOnClear: true });

useEffect(() => {
  const tasks = ids.map(id =>
    limit(() => api.get(`users/${id}`).json<User>())
  );

  Promise.allSettled(tasks).then(setResults);

  return () => {
    // Cancel queued requests on unmount (running ones finish normally)
    limit.clearQueue();
  };
}, [ids]);
Enter fullscreen mode Exit fullscreen mode

ky vs fetch: Comparison

fetch + p-limit ky + p-limit
Concurrency control ✓ p-limit ✓ p-limit
Auto retry Write it yourself ✓ built-in
Timeout Manual AbortController ✓ built-in
Auth header Add manually each time ✓ beforeRequest hook
Error messages Parse manually ✓ beforeError hook
Bundle size fetch is native +4KB

If you only need concurrency control, }}">fetch + p-limit is enough.
If you also need retry and auth handling, ky removes a lot of boilerplate.

Summary

ky and p-limit don't overlap: ky manages the lifecycle of each request, p-limit manages how many requests run at the same time. Batch API calls run into both problems. Using them together is the natural fit.

References

Top comments (0)