Rate Limiting
How OptStuff rate limiting works: design rationale vs other algorithms, dual-layer limits, Upstash floating-window (approximated sliding), configuration, 429 responses, and tuning.
Rate limiting caps how fast a single API key can consume OptStuff. That reduces abuse, avoids surprise bills from runaway traffic, and keeps capacity fair when many clients share the service. Limits are enforced per API key, in Redis, and are visible to every instance so counts stay consistent globally.
Why OptStuff uses this approach
There is no single algorithm that is “best” for every product. The choice depends on how quota should refill, how much compliant traffic is allowed to bunch up at window edges, and what it costs to run at scale. OptStuff’s constraints are:
- Multiple app instances → counts must live in a shared store (Redis via Upstash), not only in process memory.
- Image workloads → need both short-horizon control (sudden spikes) and long-horizon control (sustained overuse and cost).
- Fair, predictable abuse bounds → prefer limiting the classic fixed-window boundary burst (two back-to-back compliant bursts can briefly approach ~2× the nominal rate in real time).
- Global API → avoid calendar-day fixed buckets tied to a single timezone’s midnight where possible; rolling windows sidestep that class of edge case.
- Reliability under abuse → if Redis is down, the limiter fails closed with
503so expensive image processing and quota integrity stay protected (see Design Notes).
How common families compare
| Family | How quota behaves | Boundary / burst shape | Cost at scale |
|---|---|---|---|
| Fixed window | Counter resets on each clock bucket | Compliant traffic can pile up near edges and briefly approach ~2× the limit across a boundary | Cheap; very easy to explain (“resets at :00”) |
| True sliding window | Count every request whose timestamp falls in the last N seconds | Smooth; matches “last minute / last 24h” literally | Expensive: many writes or large per-key state at high queries per second (QPS) |
| Approximated sliding window (“floating window”) | Weight the previous fixed bucket + full current bucket to estimate the sliding count | Nearly as smooth as true sliding; blocks the worst boundary doubling with far less state | Moderate — a small, fixed number of Redis operations per check |
| Token bucket | Bucket size = burst; refill rate = long-term average | Explicit burst vs steady in one mechanism | Different knobs and docs; great when that model matches your product language |
What OptStuff chose. Per API key we use approximated sliding windows via Ratelimit.slidingWindow in the dashboard app (rate-limiter.ts) — the same family of approach used by many production edge limiters, without storing every request timestamp.
Why not fixed windows here? They are simpler to describe but allow the boundary burst above; for per-minute and rolling-day protection we want smoother enforcement.
Why not a strict per-request sliding list? Correctness would be marginally tighter, but Redis and CPU cost would not match OptStuff’s scale goals; the floating-window approximation is the usual trade-off.
Why not a token bucket (for these limits)? Token buckets are an excellent pattern when you want one mechanism with separate burst capacity and refill interval. OptStuff instead uses two independent sliding limits (per minute and per rolling day). That maps directly to “spike vs all-day volume,” stays easy to reason about in docs and headers, and does not require customers to learn bucket refill semantics.
The sections below cover defaults for those two layers, then the exact floating-window math and a worked example.
Dual-Layer Limits
Every request is checked against two independent limits. Both must pass.
| Layer | Default | Window | Purpose |
|---|---|---|---|
| Per-day | 10,000 requests | 24 hours (rolling) | Catch sustained overuse |
| Per-minute | 60 requests | 1 minute (rolling) | Catch sudden bursts |
Each layer uses the same approximated sliding window (often called a floating window): counts are blended across two adjacent time buckets instead of resetting all capacity on a sharp clock tick. Implementation is Ratelimit.slidingWindow from the Upstash Rate Limit SDK in the dashboard app (rate-limiter.ts).
Approximated sliding window in OptStuff
Sliding window (floating window) builds on the fixed window: time is still sliced into buckets of the window length (for per-minute limits, each bucket is one clock minute, e.g. 00:00:00–00:01:00). Instead of treating only the current bucket as “the count,” the limiter blends the previous bucket and the current bucket so enforcement behaves like a rolling lookback, without storing every request timestamp.
At any instant the limiter:
- Reads the count in the previous fixed window (e.g. the last full minute).
- Reads the count in the current fixed window (the minute in progress).
- Weights the previous window’s count by how much that window still overlaps a “true” sliding lookback ending at now.
- Adds (weighted previous) + (unweighted current) and compares that estimate to your limit.
Worked example (per-minute limit)
Suppose the limit is 10 requests per 1 minute. Buckets align to clock minutes: window 1 is 00:00:00–00:01:00, window 2 is 00:01:00–00:02:00, and so on.
At 00:01:15 (15 seconds into the current minute), say 4 requests were counted in the previous minute and 5 requests so far in the current minute. The usual approximation is:
limit = 10
# Weighted contribution from the old bucket + full count in the current bucket
rate = 4 × ((60 − 15) / 60) + 5
= 4 × (45 / 60) + 5
= 8
allow ⇔ rate < limit # here 8 < 10 → allowThe factor (60 − 15) / 60 is the fraction of a true last-60-seconds window that still lies inside the previous clock minute when now is 15 s past the boundary (45 s of the last minute of real time overlap the old bucket → weight 45/60).
In one line (same shape as common floating-window docs):
approximation = (prevWindowCount × prevWindowWeight) + currentWindowCountprevWindowWeight is between 0 and 1 and moves smoothly as time advances within the current bucket, so there is no single instant where the previous bucket’s contribution drops to zero “all at once” the way a fixed window resets.
Intuition. Ask: “How much of the previous clock minute still falls inside my last-60-seconds lookback?” That fraction scales the old bucket’s count. The current minute’s count is added at full weight. Compare the sum to the limit—no sharp reset at hh:mm:00.
Pros
- Smoother than fixed window: reduces the classic boundary burst where two compliant bursts back-to-back can briefly approach ~2× the nominal rate across a bucket edge.
Cons
- More work than a single counter: needs two bucket counts (and the blend) per check—moderate Redis and CPU cost compared to one fixed-window key, but far less than a true sliding window that retains every timestamp.
- Only an approximation: it assumes requests in the previous bucket were spread evenly across that minute. If traffic was bunched at the end of the old window, the estimate can be optimistic compared to a strict sliding count.
OptStuff applies this same family of logic per API key via Ratelimit.slidingWindow in the dashboard (rate-limiter.ts) for both 1 minute and 1 day windows—the numbers above illustrate the minute case; the day layer uses the same idea with 24 h buckets.
Default Configuration
Rate limits are stored per API key in the database:
| Setting | Range | Default |
|---|---|---|
rateLimitPerMinute | 1 – 10,000 | 60 |
rateLimitPerDay | 1 – 1,000,000 | 10,000 |
New API keys are created with the default limits. Custom per-key rate limits are not yet configurable through the dashboard UI; this feature is planned for a future release. If you need to adjust limits, update the api_key table directly:
rateLimitPerMinute: valid range: 1 – 10,000rateLimitPerDay: valid range: 1 – 1,000,000
Before modifying: back up the
api_keytable and validate that your values fall within the ranges above (the schema enforces integer type but not range; out-of-range values may cause unexpected behaviour). If you are unsure about the correct limits for your use case, contact support.
Changes take effect within 60 seconds (cache TTL).
Response When Rate Limited
{
"error": "Rate limit exceeded",
"reason": "Too many requests per minute",
"retryAfter": 12,
"limit": 60
}retryAfter is the number of seconds to wait before retrying (consistent with the HTTP Retry-After header).
HTTP headers:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0Clients should respect the Retry-After header to avoid further 429 responses.
Tuning Recommendations
| Traffic Level | Recommendation |
|---|---|
| Low (< 100 req/day) | Defaults work well |
| Medium (100 – 10,000 req/day) | Monitor analytics; increase per-day limit if hit legitimately |
| High (> 10,000 req/day) | Set per-key limits based on expected traffic to avoid false 429s |
Client-Side Retry Strategy
When receiving a 429 response, implement exponential backoff with jitter:
async function fetchWithRetry(
url: string,
maxRetries: number = 3
): Promise<Response> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url);
if (response.status !== 429) return response;
if (attempt === maxRetries) return response;
const retryAfter = response.headers.get("Retry-After");
const baseDelay = retryAfter
? Number(retryAfter) * 1000
: Math.pow(2, attempt) * 1000;
const jitter = Math.random() * 1000;
await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
}
throw new Error("Unreachable");
}Key principles:
- Respect
Retry-After: always prefer the server-provided delay over your own backoff - Add jitter: prevents multiple clients from retrying in sync (thundering herd)
- Limit retries: 3 retries is usually sufficient; more indicates a capacity problem
CDN and Rate Limiting Interaction
If you place a CDN (Cloudflare, CloudFront, Vercel Edge) in front of OptStuff:
| Scenario | Rate Limit Impact |
|---|---|
| CDN cache hit | No request reaches OptStuff, so the rate limit is not consumed |
| CDN cache miss | Request reaches OptStuff and the rate limit is consumed |
| First request for a URL | Always consumes quota (cache is cold) |
This means rate limits primarily affect unique or uncached requests. For high-traffic sites with good CDN cache hit ratios, rate limits are rarely a concern.
See CDN and Caching for cache optimization strategies.
Monitoring Recommendations
| What to Monitor | Why | Suggested Alert |
|---|---|---|
429 response rate | Detect legitimate traffic hitting limits | Alert when 429 rate exceeds 5% of total requests |
| Per-key daily usage | Catch keys approaching limits | Alert at 80% of daily quota |
| Redis connectivity | Rate limiter depends on Redis | Alert on Redis connection errors |
When Redis is unavailable, rate-limited routes return 503 with Retry-After. Monitor Redis health and alert before this becomes customer-visible.
Design Notes
- Rate limits are checked after signature verification so that unauthenticated requests cannot exhaust quota.
- The per-minute limit is checked before the per-day limit so short bursts do not consume daily quota after they have already exceeded the minute window (see Redis Schema for details).
- Both
GETandHEADrequests consume quota because both run through the same authentication and abuse-protection pipeline. - If Redis is temporarily unavailable, the limiter fails closed with
503; clients should respectRetry-Afterand operators should check Redis health.
Related Documentation
- Key Management: Managing API keys (per-key rate limits not configurable in dashboard yet)
- CDN and Caching: Cache strategies that reduce rate limit pressure
- Error Codes: Rate limit error details
- Security Best Practices: Broader security context
Last updated on
Referer Security Model
Why the browser Referer header helps with anti-hotlinking, why it is not a substitute for authentication, and how OptStuff enforces it alongside signed URLs.
Key Management
Best practices for API key lifecycle management — creation, rotation, revocation, expiration, and environment-specific strategies.