Skip to content
Command Palette
Search for a command to run...
QuizBase · Docs

Rate limits in practice#

Companion to Errors and retries — this guide shows real patterns for production traffic.

Plan your capacity#

TierRequests/minuteRequests/dayGood for
Free60500Prototypes, hobby apps, low-traffic blogs
Pro60050,000Indie apps, medium SaaS, production games
Team3,000500,000Studios, CMS integrations, high-traffic apps
EnterprisenegotiatednegotiatedCustom 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 &lt; 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):

  1. Put the cursor URL on a queue (BullMQ, SQS, Redis stream)
  2. Worker picks off one URL, fetches, persists, pushes the _links.next back
  3. Worker respects rate limits — if RateLimit-Remaining < 5, sleep RateLimit-Reset seconds
  4. 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 &lt; 3 && !isClientError(err)
});

Monitoring checklist#

  • ✅ Log X-Request-Id on every non-2xx response — makes support tickets trivial
  • ✅ Alert when RateLimit-Remaining / RateLimit-Limit < 0.2 — lets you upgrade proactively
  • ✅ Track Retry-After histogram — 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#