async
request module
rate limiting
asynchronous programming
node.js

Limiting requests with the async and request modules

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

When you call an API from Node.js, there are two limits you usually need to enforce at the same time: how many requests can run in parallel, and how many requests you send per second or minute. The old request package still appears in legacy services, and it is often paired with async utilities such as eachLimit and queue. Even if your team later migrates to fetch or axios, the same control patterns still apply.

The practical goal is predictable throughput without bursts that trigger 429 errors, socket exhaustion, or unstable latency. This guide focuses on reliable request limiting with async + request, including concurrency control, rate shaping, retries, and metrics. The examples are designed for production usage, where request outcomes are mixed and external APIs may throttle aggressively.

Core Sections

1) Model concurrency and rate separately

Concurrency and rate are related but different. A concurrency limit controls in-flight work. A rate limit controls launch frequency. If you enforce only concurrency, your process can still burst traffic at the start of each batch. If you enforce only rate, long-running requests can pile up and consume memory.

A useful starting point is:

  • maxInFlight: based on CPU, memory, and downstream connection limits.
  • requestsPerSecond: based on provider documentation and observed 429s.
  • retryBudget: maximum retries per item so failures do not stall the queue.

2) Limit in-flight calls with async.eachLimit

Use eachLimit to cap parallel calls while still processing a large input list.

javascript
1const async = require('async');
2const request = require('request');
3
4function fetchJson(url, cb) {
5  request({ url, json: true, timeout: 5000 }, (err, res, body) => {
6    if (err) return cb(err);
7    if (res.statusCode >= 400) {
8      return cb(new Error(`HTTP ${res.statusCode}`));
9    }
10    cb(null, body);
11  });
12}
13
14const urls = [/* many URLs */];
15const results = [];
16
17async.eachLimit(urls, 8, (url, next) => {
18  fetchJson(url, (err, body) => {
19    if (!err) results.push(body);
20    next(err);
21  });
22}, (err) => {
23  if (err) console.error('Batch failed:', err.message);
24  else console.log('Done:', results.length);
25});

This protects your process from opening too many sockets, but it does not guarantee a requests-per-second ceiling.

3) Add request-per-second shaping with async.queue

Pair a queue with a token refill interval to smooth request start times.

javascript
1const async = require('async');
2const request = require('request');
3
4const maxInFlight = 6;
5const rps = 10;
6let tokens = rps;
7setInterval(() => { tokens = rps; }, 1000);
8
9const q = async.queue((task, done) => {
10  const run = () => {
11    if (tokens <= 0) return setTimeout(run, 20);
12    tokens -= 1;
13
14    request(task.opts, (err, res, body) => {
15      if (err) return done(err);
16      if (res.statusCode === 429) return done(new Error('rate-limited'));
17      done(null, { status: res.statusCode, body });
18    });
19  };
20  run();
21}, maxInFlight);

This design prevents sharp launch bursts. It also keeps rate control in one place so later migration away from request is straightforward.

4) Retries, backoff, and observability

Retries should be selective. Retry on transient network errors and 5xx statuses, but not on 4xx validation errors. Add jitter so many workers do not retry at exactly the same millisecond.

javascript
1function backoffMs(attempt) {
2  const base = Math.min(1000 * 2 ** attempt, 15000);
3  return base + Math.floor(Math.random() * 250);
4}

Track these metrics during rollout: success rate, p95 latency, queue depth, retry count, and 429 count. If 429 rises while queue depth is stable, reduce RPS. If queue depth keeps growing but 429 is near zero, raise concurrency carefully.

Common Pitfalls

  • Using Promise.all on a large list and accidentally launching thousands of requests at once.
  • Treating concurrency limit as a full rate limiter, then getting periodic bursts and 429 responses.
  • Retrying all non-200 responses, including permanent 4xx errors that should fail fast.
  • Ignoring timeout settings and allowing slow upstream calls to block queue slots indefinitely.
  • Migrating from request without keeping equivalent backpressure and observability controls.

Summary

async and request can still provide stable request limiting in legacy Node.js services when you separate concurrency from rate, add bounded retries, and monitor live behavior. Start with conservative limits, then tune using production metrics rather than guesswork. If your team later adopts modern HTTP clients, keep the same architecture: queue-based backpressure, explicit throttle logic, and targeted retry policy. Those principles, not the client library, are what keep high-volume integrations reliable.


Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.