TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Implement Rate Limiting in a Node.js API

Practical insights, clear explanations, and useful ideas for understanding the web.

October 26, 20267 min read5 topics
How to Implement Rate Limiting in a Node.js API
Explore the details
#Node.js#Express#API security#Rate limiting#Backend development
October 26, 20267 min read

Rate limiting helps protect a public API from accidental request bursts and abusive traffic. This tutorial shows how to add configurable limits to an Express API with express-rate-limit, return a useful HTTP 429 response, and configure client IP detection when your app is behind a proxy. It also explains why a process-local limit does not work reliably across multiple server instances.

Version note: Live documentation retrieval was unavailable while preparing this article. The example uses the established express-rate-limit API; confirm option names against the documentation for the version installed in your project before deploying.

Key Takeaways

  • express-rate-limit can apply a request limit to an Express app or a specific route.
  • Set limit and windowMs to control how many requests a client may make and over what period.
  • Return HTTP 429 when the limit is exceeded; expose rate-limit headers so clients can understand their remaining allowance.
  • The default in-memory store is local to one Node.js process. Use a shared store when requests can reach multiple instances.
  • Configure Express proxy trust to match your actual network topology. Trusting arbitrary forwarded headers can let clients spoof their IP address.

Install and add a basic API limit

Install Express and express-rate-limit in an existing Node.js project:

npm install express express-rate-limit

The following JavaScript example limits requests to /api to 100 per 15-minute window per client IP. It returns JSON with status 429 when a client exceeds the limit.

import express from 'express';
import { rateLimit } from 'express-rate-limit';

const app = express();

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: true,
  legacyHeaders: false,
  statusCode: 429,
  handler: (_req, res, _next, options) => {
    res.status(options.statusCode).json({
      error: 'rate_limit_exceeded',
      message: 'Too many requests. Try again later.',
    });
  },
});

app.use('/api', apiLimiter);

app.get('/api/status', (_req, res) => {
  res.json({ status: 'ok' });
});

app.listen(3000, () => {
  console.log('API listening on port 3000');
});

This example uses ES module imports. Your project must be configured for ES modules, for example by setting "type": "module" in package.json, or you can adapt the imports to your project’s module format.

The limiter applies to requests that reach /api, including /api/status. Change windowMs and limit to fit the endpoint’s expected usage. A limit that is appropriate for a read-only endpoint may be too permissive for an expensive operation or too strict for a bulk client.

With standardHeaders: true, the middleware adds standard rate-limit response headers. legacyHeaders: false disables the older X-RateLimit-* headers. Clients should treat these headers as guidance, not as a substitute for handling a 429 response. The JSON handler makes the error body predictable for API clients; it does not disable the rate-limit headers.

Choose the key and scope deliberately

By default, rate limiting is generally based on the request IP. That is a reasonable starting point for a public endpoint without authentication, but it has trade-offs: users behind the same NAT may share a limit, and an attacker may spread requests across many addresses.

For an authenticated API, you may want a limit keyed to a verified account or API key instead. Do not derive that identity from a caller-controlled header. Authenticate the request first, then use identity established by your authentication layer. Consider keeping an IP-based limit as a separate layer to constrain unauthenticated traffic and reduce abuse of the authentication endpoint.

Scope limits according to the risk and cost of the operation. A broad /api limit is easy to add, but separate limits can be more appropriate for login, search, or resource-intensive routes. A rate limit is one control among several; it does not replace authentication, authorization, input validation, or capacity planning.

Configure trusted proxies carefully

When Express runs behind a load balancer or reverse proxy, the connection IP seen by Node.js may be the proxy’s address. The proxy may forward the original client IP in X-Forwarded-For, but that header is only useful if your app trusts the proxy that sets it.

Configure Express trust proxy according to the actual deployment. For example, if every request reaches the app through exactly one trusted proxy, a one-hop setting may be appropriate:

app.set('trust proxy', 1);

Do not copy this setting without checking the request path. If clients can reach the app directly, or requests can pass through different numbers of proxies, a hop count may trust an address supplied or influenced by the client. Prefer configuring the specific trusted proxy addresses or subnets when your infrastructure permits it, and ensure the proxy overwrites or sanitizes forwarded headers. Never assume that X-Forwarded-For is trustworthy merely because it exists.

Test the configured behavior from the same network path used in production. Incorrect proxy trust can cause all users to share a proxy’s limit, or can allow clients to evade limits by spoofing forwarded IP values.

Why in-memory limits fail across instances

The default in-memory store keeps counters inside one Node.js process. It is useful for local development or a single-process deployment, but it is not a shared record of API usage.

With multiple Node.js workers, containers, or servers, each process can maintain a separate counter. A client whose requests are distributed across instances may therefore receive more requests than the configured limit. Counters can also reset when a process restarts, and separate application replicas do not see each other’s in-memory state.

For a horizontally scaled API, use a shared store supported by the rate-limiting library, commonly backed by a centralized service such as Redis. Check the store’s documentation for its compatibility with your installed library version. Verify that increments are atomic and that all API instances use the same store and key strategy. A shared store adds network dependency and operational considerations, so set sensible connection timeouts and monitor store availability and latency.

Test the limit and the response

For a quick local check, temporarily lower the limit, start the server, and send requests to the limited route. For example, with a limit of two requests in a one-minute window, the third request from the same client should receive HTTP 429. Inspect the response status, JSON body, and rate-limit headers. Restore an appropriate limit before deploying.

Also test the real deployment path: send requests through the proxy or load balancer, confirm the app sees the intended client identity, and repeat the test across different app instances. If you use a shared store, test what happens when that store is unavailable and make an intentional choice about whether requests should be allowed or rejected in that condition.

Common Mistakes

  • Leaving the default in-memory store in a multi-instance deployment: Each process can count requests independently. Use a shared store when instances must enforce one common limit.
  • Trusting every proxy or forwarded address: Overly broad proxy trust can undermine client-IP-based controls. Configure trust to match the actual infrastructure.
  • Treating a 429 as an unexpected server error: Clients should recognize the status and back off rather than immediately retrying at the same rate.
  • Using one limit for every endpoint: Authentication and expensive operations may need different policies from ordinary reads.
  • Assuming IP limits identify individual users: Shared networks and changing IP addresses make IP-based limits an imperfect identity mechanism.
  • Treating rate limiting as a complete security control: It does not prevent unauthorized access or replace validation and abuse monitoring.

Best Practices

  • Start with limits based on expected client behavior, then tune them using production traffic and operational needs.
  • Use a verified account or API-key identity for authenticated quotas, while considering separate IP-based protections for public entry points.
  • Return a stable error format and HTTP 429 for rejected requests.
  • Keep standard rate-limit headers enabled when they help clients manage their request rate.
  • Use a shared store for distributed deployments and monitor its health.
  • Review proxy trust settings whenever the network topology changes.

Conclusion

express-rate-limit provides a straightforward way to set configurable limits and return a clear 429 response in an Express API. For reliable production behavior, choose the limiting identity carefully, configure proxy trust to fit your deployment, and move counters to shared storage when requests can reach multiple server instances.