MK
Manjit KumarFull Stack Developer
Contact
Backend

API Rate Limiting: Protecting a Mobile App and Its Backend

20 October 202617 min read4 views
API Rate Limiting: Protecting a Mobile App and Its Backend
#Api Security#Rate Limiting#Node.Js#Express.Js#Redis#React Native#Typescript

API Rate Limiting: Protecting a Mobile App and Its Backend

Every mobile application communicates with a backend through API requests. Loading a feed, searching for products, refreshing a profile, and submitting a form can all generate traffic.

But what happens when a client sends too many requests?

A bug might trigger repeated requests. A user might repeatedly tap a button. A bot might attempt thousands of login combinations. Or a sudden traffic spike might overwhelm your server.

This is where API rate limiting becomes essential.

Rate limiting controls how frequently a client can access a resource. It helps protect backend capacity, reduce abuse, and provide predictable behavior when traffic increases.

In this guide, we'll explore rate-limiting algorithms, implement Express middleware, understand Redis-based distributed limits, and handle HTTP 429 Too Many Requests correctly in a React Native application.

1. What Is API Rate Limiting?

API rate limiting restricts the number of requests a client can make within a defined period or according to a defined traffic policy.

For example, an API might allow:

  • 100 general API requests per minute per authenticated user.
  • 5 login attempts per minute per account or source.
  • 20 search requests per minute per user.
  • 10 password-reset requests per hour for a defined identity or source.

These are illustrative values, not universal recommendations. Actual limits should reflect the endpoint's cost, normal usage, security risks, and product requirements.

When a client exceeds its limit, the server may return:

http
HTTP/1.1 429 Too Many Requests Content-Type: application/json Retry-After: 30
json
{ "error": { "code": "RATE_LIMITED", "message": "Too many requests. Please try again later." } }

The 429 status tells the client that it has exceeded a request-rate policy. The optional Retry-After header can indicate when it should try again.

Important: rate limiting is not a complete DDoS defense. Large-scale attacks may require infrastructure-level controls, a CDN or web application firewall, network filtering, and additional capacity protections.

2. Why Do Mobile Apps Need Rate Limiting?

Mobile applications introduce several challenges.

Accidental request storms

Imagine a React Native screen that fetches data whenever a component renders. If the effect dependencies are incorrect, it might send requests repeatedly.

Repeated user actions

A user might tap the login or payment button multiple times because the interface appears unresponsive.

Automated abuse

An attacker could automate login attempts, scrape public data, or repeatedly invoke expensive search endpoints.

Unexpected traffic spikes

A notification, product launch, or application update might cause many clients to request the same resources simultaneously.

Expensive backend operations

Search, report generation, file processing, and AI-powered endpoints can consume significantly more resources than simple database reads.

Rate limiting helps manage these risks, but it should complement good application design, input validation, authentication, authorization, caching, and monitoring.

3. Rate Limiting vs. Throttling vs. Quotas

These terms are related, but they describe different policies.

ConceptMeaningExample
Rate limitingRestricts request frequency100 requests per minute
ThrottlingSlows or controls the rate of processingProcess requests at a controlled pace
QuotaLimits total usage over a longer period10,000 API calls per month
Concurrency limitRestricts simultaneous operations5 report-generation jobs at once

A rate limiter commonly rejects requests after a threshold is reached. A throttling system may instead queue or delay work.

For many REST APIs, rejecting excess requests with 429 is a straightforward starting point.

4. How a Rate Limiter Works

A basic rate limiter maintains a counter or other state associated with a client.

For a fixed-window policy, the process is:

  1. Identify the client.
  2. Read or increment its request counter.
  3. Check the configured threshold.
  4. Allow the request if the threshold has not been exceeded.
  5. Otherwise, return 429 Too Many Requests.
  6. Reset or expire the counter when the window ends.
Diagram

Scroll to zoom · Drag to pan

The important detail is that the limit check must be reliable under concurrent requests. If multiple requests update a counter simultaneously, a poorly implemented limiter can allow more requests than intended.

5. Common Rate-Limiting Algorithms

Different algorithms offer different trade-offs in accuracy, memory usage, and burst handling.

A. Fixed window counter

The fixed-window algorithm counts requests within a predefined interval.

For example, allow 100 requests between 10:00:00 and 10:00:59.

Advantages

  • Simple to understand.
  • Low memory usage.
  • Easy to implement with counters and expiration.

Disadvantage

A client could send 100 requests just before the window ends and another 100 immediately after it begins again. This creates a burst of 200 requests within a short period.

Fixed windows are useful for straightforward limits, but the boundary behavior matters.

B. Sliding window log

This algorithm records the timestamps of individual requests and counts those inside the current rolling window.

For a limit of 100 requests per minute, it considers the requests made during the previous 60 seconds.

Advantages

  • Accurate rolling-window behavior.
  • Avoids the fixed-window boundary burst.

Disadvantages

  • Stores individual request timestamps.
  • Memory usage grows with request volume.

It can be suitable for sensitive endpoints where precise rolling-window behavior is important, provided its storage cost is acceptable.

C. Sliding window counter

This algorithm combines counters from adjacent windows to estimate the number of requests in a rolling interval.

Advantages

  • Lower memory usage than a full timestamp log.
  • Smoother behavior around window boundaries.

Disadvantage

  • The result is an approximation rather than an exact timestamp-by-timestamp count.

It can be a practical compromise for higher-volume APIs.

D. Token bucket

The token-bucket algorithm maintains a bucket containing a limited number of tokens.

Each request consumes a token. Tokens refill over time, up to a maximum capacity.

For example:

  • Bucket capacity: 10 tokens.
  • Refill rate: 1 token per second.
  • Each request consumes 1 token.

A client can make a burst of up to 10 requests when the bucket is full. Afterward, sustained traffic is limited by the refill rate.

Advantages

  • Supports controlled bursts.
  • Enforces a long-term average rate.
  • Useful for APIs with variable traffic patterns.

E. Leaky bucket

A leaky-bucket policy controls the rate at which work is processed or accepted. Depending on the implementation, excess traffic may be queued or rejected.

It is useful when smoothing traffic is more important than allowing bursts.

Which algorithm should you choose?

RequirementReasonable starting point
Simple per-minute API limitsFixed window
Accurate rolling-window policySliding window log
Rolling-window behavior with modest storageSliding window counter
Controlled burstsToken bucket
Smooth processing rateLeaky bucket or a bounded queue

No algorithm is universally best. Choose according to the policy you need to enforce and test its behavior under concurrent requests.

6. Implement Basic Rate Limiting in Express

For a Node.js API using Express, express-rate-limit provides middleware for common rate-limiting requirements.

Install the packages:

bash
npm install express express-rate-limit npm install -D typescript @types/express @types/node

The following example uses TypeScript and the current express-rate-limit API style.

typescript
import express from "express"; import { rateLimit } from "express-rate-limit"; const app = express(); app.use(express.json()); const apiLimiter = rateLimit({ windowMs: 60 * 1000, limit: 100, standardHeaders: "draft-8", legacyHeaders: false, message: { error: { code: "RATE_LIMITED", message: "Too many requests. Please try again later.", }, }, }); app.use("/api", apiLimiter); app.get("/api/products", (_req, res) => { res.json({ products: [], }); }); app.listen(3000, () => { console.log("API server running on port 3000"); });

This example allows up to 100 requests per configured window for each rate-limit key. With the default in-memory store, that state is local to the running process.

Important production considerations

  • The default memory store does not share counters between separate server instances.
  • In-memory state resets when the process restarts.
  • Use a shared store when a distributed deployment needs a shared rate-limit policy.
  • Configure trusted proxy behavior carefully if the application sits behind a load balancer or reverse proxy.
  • Apply different limits to endpoints with different risks and costs.

The package supports standard rate-limit response headers and custom stores. Read its documentation before selecting options for your deployment.

Documentation: https://express-rate-limit.mintlify.app/

7. Use Different Limits for Different Endpoints

A single limit for every endpoint is rarely the best policy.

A public product-list endpoint and a password-reset endpoint have different security and resource requirements.

For example:

typescript
import { rateLimit } from "express-rate-limit"; export const generalApiLimiter = rateLimit({ windowMs: 60_000, limit: 100, standardHeaders: "draft-8", legacyHeaders: false, }); export const loginLimiter = rateLimit({ windowMs: 60_000, limit: 5, standardHeaders: "draft-8", legacyHeaders: false, message: { error: { code: "LOGIN_RATE_LIMITED", message: "Too many login attempts. Try again later.", }, }, });

Apply them to the relevant routes:

typescript
app.use("/api", generalApiLimiter); app.post("/api/auth/login", loginLimiter, loginHandler);

The login limit above is illustrative. Authentication policies need careful design to avoid locking out legitimate users or allowing attackers to bypass the limit by changing their source IP.

For authentication endpoints, consider multiple signals, such as:

  • Account or normalized identity.
  • Source IP or network.
  • Device or session signals where appropriate.
  • Global protection against excessive traffic.

Do not use a user-supplied account ID as a trusted identity before validating it.

8. Why Redis Helps in Distributed Systems

Imagine your backend runs on three instances behind a load balancer.

If each server independently permits 100 requests per minute, a client may make more than 100 requests across the deployment because each instance maintains its own counter.

Diagram

Scroll to zoom · Drag to pan

Redis can provide shared rate-limit state across API instances.

Redis operations are fast, and atomic scripts can update related state without allowing concurrent requests to interleave the critical operations.

However, using Redis is not automatically enough. The algorithm, key design, expiration, connection handling, and failure policy all matter.

What should the rate-limit key represent?

Common key patterns include:

text
rate-limit:user:123 rate-limit:ip:203.0.113.10 rate-limit:route:search:user:123

These are examples of key naming, not a complete security design.

Use authenticated user identifiers for per-user limits where appropriate. Use IP-based limits as an additional layer, especially for anonymous traffic and authentication endpoints.

Avoid trusting arbitrary client-provided headers for identity. Behind a reverse proxy, configure Express's trust proxy setting only for infrastructure you control; otherwise, IP-based limits may be bypassed or applied to the wrong address.

9. A Redis Fixed-Window Counter

The following Lua script illustrates an atomic fixed-window counter using Redis.

It increments the counter, initializes its expiration on the first request, and returns the count and remaining window time.

lua
local key = KEYS[1] local window_seconds = tonumber(ARGV[1]) local count = redis.call("INCR", key) if count == 1 then redis.call("EXPIRE", key, window_seconds) end local ttl = redis.call("TTL", key) return { count, ttl }

The API can compare the returned count with its configured limit.

Conceptually:

typescript
const [count, ttl] = await redis.eval( script, 1, rateLimitKey, 60, ) as [number, number]; if (count > limit) { // Return HTTP 429 and an appropriate retry delay. }

This snippet demonstrates the counter operation, not a complete Redis rate-limiting library. A production implementation should also validate results, handle Redis errors, set safe key expiration behavior, and return consistent headers.

The fixed-window boundary limitation still applies.

For more advanced algorithms, Redis documents token buckets and other approaches using atomic Lua scripts.

Reference: https://redis.io/docs/latest/develop/use-cases/rate-limiter/

10. Return HTTP 429 Correctly

When the limit is exceeded, the API should return a consistent response.

For example:

typescript
res .status(429) .set("Retry-After", "30") .json({ error: { code: "RATE_LIMITED", message: "Too many requests. Please try again later.", }, });

The Retry-After value shown here is an example. A real implementation should calculate a suitable delay from its policy and current rate-limit state.

Avoid returning a generic 500 Internal Server Error simply because a client exceeded its limit. A 500 normally indicates a server-side failure, while 429 communicates a request-rate restriction.

Useful response headers

Rate-limiting implementations may expose:

  • The configured limit.
  • The remaining allowance.
  • Information about when the limit resets.
  • Retry-After when a retry delay is appropriate.

Follow the header format supported by your middleware and API contract. Do not assume every implementation uses the same legacy or standardized header names.

11. Handle Rate Limits in React Native

The backend controls the policy, but the mobile app should respond gracefully.

If a user searches for a product and receives a 429, the app should not immediately retry the same request repeatedly.

A better approach is to:

  1. Detect the 429 status.
  2. Read Retry-After when present.
  3. Show a clear message or defer the request.
  4. Retry only when appropriate.
  5. Preserve the user's input.

Axios example

typescript
import axios from "axios"; export const api = axios.create({ baseURL: process.env.EXPO_PUBLIC_API_URL, timeout: 15_000, }); export async function searchProducts(query: string) { try { const response = await api.get("/api/products", { params: { q: query }, }); return response.data; } catch (error) { if (axios.isAxiosError(error)) { if (error.response?.status === 429) { const retryAfter = error.response.headers["retry-after"]; throw new Error( retryAfter ? `Too many requests. Try again in ${retryAfter} seconds.` : "Too many requests. Please try again shortly.", ); } } throw error; } }

This example converts a rate-limit response into a user-facing error. It does not automatically retry, which is often the safer default for interactive requests.

EXPO_PUBLIC_API_URL is configuration, not a secret. Values embedded in a mobile app can be inspected by users.

What if the app should retry automatically?

Automatic retries can be useful for idempotent reads, but they should respect the server's retry guidance and avoid repeated attempts while the limit remains active.

A simple delay parser might look like this:

typescript
function getRetryDelayMs(value: string | undefined): number | null { if (!value) return null; // Retry-After can be a number of seconds or an HTTP date. const seconds = Number(value); if (Number.isFinite(seconds) && seconds >= 0) { return seconds * 1000; } const date = Date.parse(value); if (!Number.isNaN(date)) { return Math.max(0, date - Date.now()); } return null; }

Use it as part of a controlled retry policy, not as an instruction to sleep indefinitely. Set maximum delays, honor cancellation, and avoid retrying non-idempotent operations unless the backend provides appropriate idempotency guarantees.

For example, a timed-out order-creation request may have succeeded on the server even if the client never received its response. Retrying without an idempotency key can create duplicate orders.

12. Prevent the Client From Causing Its Own Rate Limit

Rate limiting is also a reason to improve request behavior inside the app.

Debounce search input

Instead of calling the API on every keystroke, wait until the user pauses typing.

typescript
import { useEffect, useState } from "react"; export function useDebouncedValue<T>(value: T, delayMs = 300) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, delayMs); return () => clearTimeout(timer); }, [value, delayMs]); return debouncedValue; }

Use the debounced value for search requests rather than the raw input value.

This reduces unnecessary requests, although the exact behavior depends on how the search screen is implemented.

Cancel outdated requests

When a user changes a search query, an older request may no longer be useful. Axios supports cancellation through AbortController.

typescript
const controller = new AbortController(); try { const response = await api.get("/api/products", { params: { q: query }, signal: controller.signal, }); return response.data; } finally { // Call controller.abort() when the request is no longer needed, // such as when a newer search replaces it. }

The example shows how to pass a cancellation signal; a real component should keep the controller in a lifecycle-aware location and abort the previous request when appropriate.

Cancellation prevents unnecessary client work, but it does not guarantee that the server has stopped processing a request it already received.

Cache repeated reads

If a screen repeatedly fetches the same data, caching can reduce API traffic. Server-state libraries such as TanStack Query can help manage caching, deduplication, and stale data.

Disable duplicate submissions

For forms that create records or initiate payments, track the submission state and disable repeated actions while a request is pending. Keep server-side idempotency as the ultimate protection against duplicate processing.

13. Rate Limiting Is Not Authentication or Authorization

These concepts solve different problems.

  • Authentication establishes who or what is making a request.
  • Authorization determines whether that identity can perform the action.
  • Rate limiting controls the frequency of requests.

An endpoint can be rate-limited and still have broken authorization. For example, limiting an API to 100 requests per minute does not prevent one user from reading another user's private records if object-level authorization is missing.

Use all three where appropriate.

14. Common Mistakes to Avoid

Using only in-memory counters in a multi-instance deployment

Each instance enforces its own independent limit.

Better: use a shared rate-limit store or an appropriate gateway-level policy.

Trusting client-supplied identity headers

Attackers may modify headers if the backend treats them as authoritative.

Better: derive identity from validated authentication and trusted infrastructure.

Applying the same limit everywhere

An expensive export endpoint and a simple cached read have different resource costs.

Better: define limits according to endpoint behavior, user experience, and abuse risk.

Retrying immediately after HTTP 429

Immediate retries can keep the client over the limit.

Better: respect Retry-After, use bounded retries, and avoid retry storms.

Treating rate limiting as complete DDoS protection

An application-level limiter may not absorb attacks that saturate network bandwidth or infrastructure before requests reach the application.

Better: combine application controls with infrastructure protections appropriate to the threat model.

Ignoring Redis failures

A shared rate limiter introduces a dependency. If Redis becomes unavailable, the API needs a deliberate policy.

Better: decide whether each endpoint should fail open, fail closed, or use a bounded fallback. Sensitive authentication or expensive endpoints may need stricter behavior than low-risk reads. Monitor failures and avoid allowing an outage to silently remove all protections.

15. Production Checklist

Before deploying a rate limiter, verify the following:

  • Limits are defined for important endpoints.
  • Authentication and expensive operations receive appropriate protection.
  • The client identity is derived from trusted information.
  • Reverse-proxy and IP-address handling are configured correctly.
  • Multi-instance deployments use a suitable shared strategy.
  • Counters or buckets update atomically where required.
  • Responses use HTTP 429 when appropriate.
  • Retry guidance is accurate and bounded.
  • Mobile clients avoid retry storms.
  • Duplicate submissions are handled safely.
  • Redis or store failures have a documented policy.
  • Logs and metrics track rate-limit decisions without exposing sensitive data.
  • Limits are tested under concurrent requests and realistic traffic.
  • Legitimate users can recover after reaching a limit.

Conclusion

API rate limiting is a practical way to protect backend services, reduce accidental request storms, and make resource usage more predictable.

For a small Express API, middleware such as express-rate-limit is a useful starting point. For distributed applications, a shared store such as Redis can help enforce limits across multiple instances. On the mobile side, debounce search requests, cancel outdated work, handle 429 responses, and avoid uncontrolled retries.

The most important principle is this:

A reliable API does not just reject excessive traffic. It communicates the limit clearly and gives legitimate clients a safe way to recover.

Further Reading

Share

© 2026 Manjit Kumar - All rights reserved

Last updated

With Love, By Manjit.

;