API Rate Limiting: Protecting a Mobile App and Its Backend

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/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 30{
"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.
| Concept | Meaning | Example |
|---|---|---|
| Rate limiting | Restricts request frequency | 100 requests per minute |
| Throttling | Slows or controls the rate of processing | Process requests at a controlled pace |
| Quota | Limits total usage over a longer period | 10,000 API calls per month |
| Concurrency limit | Restricts simultaneous operations | 5 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:
- Identify the client.
- Read or increment its request counter.
- Check the configured threshold.
- Allow the request if the threshold has not been exceeded.
- Otherwise, return
429 Too Many Requests. - Reset or expire the counter when the window ends.
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?
| Requirement | Reasonable starting point |
|---|---|
| Simple per-minute API limits | Fixed window |
| Accurate rolling-window policy | Sliding window log |
| Rolling-window behavior with modest storage | Sliding window counter |
| Controlled bursts | Token bucket |
| Smooth processing rate | Leaky 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:
npm install express express-rate-limit
npm install -D typescript @types/express @types/nodeThe following example uses TypeScript and the current express-rate-limit API style.
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:
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:
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.
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:
rate-limit:user:123
rate-limit:ip:203.0.113.10
rate-limit:route:search:user:123These 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.
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:
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:
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-Afterwhen 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:
- Detect the
429status. - Read
Retry-Afterwhen present. - Show a clear message or defer the request.
- Retry only when appropriate.
- Preserve the user's input.
Axios example
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:
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.
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.
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.


