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.
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
Basic Usage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| 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);
|
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| 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;
},
],
},
});
|
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| 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));
}
|
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
1
2
3
4
5
6
7
8
9
10
11
| 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);
|
Dynamic Concurrency
Back off when the server starts rate-limiting, then recover:
1
2
3
4
5
6
7
| const limit = pLimit(10);
// 429s increasing β slow down
limit.concurrency = 3;
// Back to normal
limit.concurrency = 10;
|
Cancel Queued Requests
In a React component, cancel pending requests on unmount:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| // 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]);
|
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