Rate limits in practice#
Companion to Errors and retries — this guide shows real patterns for production traffic.
Plan your capacity#
| Tier | Requests/minute | Requests/day | Good for |
|---|---|---|---|
| Free | 60 | 500 | Prototypes, hobby apps, low-traffic blogs |
| Pro | 600 | 50,000 | Indie apps, medium SaaS, production games |
| Team | 3,000 | 500,000 | Studios, CMS integrations, high-traffic apps |
| Enterprise | negotiated | negotiated | Custom SLA, multi-region, dedicated support |
All keys on your account share one counter — qb_pk_* and qb_sk_* are scopes for browser vs server use, not separate quotas. Cap is 20 active keys per account.
Read the headers#
Every response — even 200 OK — carries IETF RateLimit-* headers:
HTTP/1.1 200 OK
RateLimit-Limit: 600
RateLimit-Remaining: 247
RateLimit-Reset: 38
RateLimit-Policy: 600;w=60 Parse once, log, alert:
Idempotency#
All GET endpoints are idempotent by definition — you can retry safely. The server de-duplicates by X-Request-Id for observability, but nothing is stored twice even if you retry without an id.
Keep X-Request-Id from your first attempt in logs so you can trace retries.
Backoff with jitter#
async function quizbaseFetch<T>(
url: string,
key: string,
opts: { maxAttempts?: number; logger?: Console } = {}
): Promise<T> {
const { maxAttempts = 5, logger = console } = opts;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(url, { headers: { 'X-API-Key': key } });
if (res.ok) return res.json() as Promise<T>;
// Permanent 4xx — don't retry
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
const body = await res.text();
throw new Error(`${res.status} ${res.statusText}: ${body}`);
}
// 429 — respect Retry-After exactly
// 5xx — exponential backoff with jitter, capped at 60s
const retryAfter = res.status === 429
? parseInt(res.headers.get('Retry-After') ?? '60', 10) * 1000
: Math.min(60_000, 1000 * 2 ** attempt) + Math.floor(Math.random() * 1000);
logger.warn(`[quizbase] attempt ${attempt} got ${res.status}, sleeping ${retryAfter}ms`);
await new Promise((r) => setTimeout(r, retryAfter));
}
throw new Error(`Exhausted ${maxAttempts} retries`);
} Pattern: cache in front#
A pre-built local cache eats 80% of your traffic. For apps that don’t need freshness:
// Simple in-memory cache for question lists (Node server)
const cache = new Map<string, { body: unknown; expires: number }>();
async function cachedQuizbase(url: string, key: string, ttlMs = 300_000) {
const cached = cache.get(url);
if (cached && cached.expires > Date.now()) return cached.body;
const body = await quizbaseFetch(url, key);
cache.set(url, { body, expires: Date.now() + ttlMs });
return body;
} For distributed caches, use Redis with SETEX and the URL as the key.
Pattern: queue + worker#
For large syncs (e.g. mirroring the Polish catalog):
- Put the cursor URL on a queue (BullMQ, SQS, Redis stream)
- Worker picks off one URL, fetches, persists, pushes the
_links.nextback - Worker respects rate limits — if
RateLimit-Remaining < 5, sleepRateLimit-Resetseconds - Dead-letter after 5 failed attempts
This pattern drains millions of requests over hours without ever hitting 429.
Pattern: SWR on the client#
Browser apps (using qb_pk_* keys) should use Stale-While-Revalidate:
// With @tanstack/query or swr
useQuery({
queryKey: ['quiz', 'random', lang],
queryFn: () => fetch('/api/round').then((r) => r.json()),
staleTime: 5 * 60 * 1000, // 5 min — stay stale, don't hammer
gcTime: 30 * 60 * 1000, // 30 min — purge from memory
retry: (count, err) => count < 3 && !isClientError(err)
}); Monitoring checklist#
- ✅ Log
X-Request-Idon every non-2xx response — makes support tickets trivial - ✅ Alert when
RateLimit-Remaining / RateLimit-Limit < 0.2— lets you upgrade proactively - ✅ Track
Retry-Afterhistogram — helps decide if you need to scale horizontally or upgrade tier - ✅ Measure your own request-rate — your app’s view vs our headers should match
- ❌ Don’t poll faster after 429 — you’ll only make it worse
- ❌ Don’t swallow 401/403 as retryable — they indicate real config issues
See also#
- Errors and retries — the primitives
- GET /v1/questions — cursor pagination for batch jobs