Rate Limiting Public Tile Endpoints
A map fetches a dozen tiles the instant a reader pans, which means any rate limit tuned for ordinary API traffic will break normal use on its first interaction. Limiting a tile endpoint means allowing bursts and constraining sustained volume — and doing it at the edge, where the limit costs nothing and the cache absorbs most of the traffic anyway.
When to Use This
A publicly reachable tile endpoint with no authentication. The threats are mundane rather than malicious: a scraper enumerating the whole pyramid, a misconfigured client in a retry loop, or someone embedding your basemap in their own product.
If the endpoint sits behind authentication, or serves only your own origins with a strict Referer policy, the case is weaker — but the runaway-client scenario applies regardless of who the client belongs to.
Specification Detail: What Shape a Limit Must Have
| Traffic pattern | Requests | Window | Legitimate? |
|---|---|---|---|
| Initial map load | 15–40 | 2 s | Yes |
| A pan or zoom step | 8–20 | 1 s | Yes |
| Sustained browsing | 40–120 | 60 s | Yes |
| Pyramid enumeration | 2,000+ | 60 s | No |
| Retry loop | 500+ on one path | 60 s | No |
The first three rows are why a naive “60 requests per minute” limit is unusable: a single pan can consume a third of it. A workable limit needs a burst allowance well above any single interaction and a sustained rate that only enumeration exceeds.
Production Command
At the edge, a token bucket with a generous burst and a moderate refill:
// Cloudflare Worker: token bucket per client IP, backed by Durable Objects
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (!url.pathname.endsWith(".mvt") && !url.pathname.endsWith(".pmtiles")) {
return fetch(request);
}
// Cache hits never reach here in a properly configured deployment, so the
// limiter only sees origin-bound traffic — which is what we care about.
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const id = env.RATE_LIMITER.idFromName(ip);
const allowed = await env.RATE_LIMITER.get(id).fetch(
new Request("https://limiter/take", { method: "POST" })
).then((r) => r.json());
if (!allowed.ok) {
return new Response("Rate limit exceeded", {
status: 429,
headers: {
"Retry-After": String(allowed.retryAfter),
"Cache-Control": "no-store",
},
});
}
return fetch(request);
},
};
export class RateLimiter {
constructor(state) {
this.state = state;
}
async fetch() {
const now = Date.now();
const BURST = 200; // a pan is ~20; this allows ten in quick succession
const REFILL_PER_SEC = 10; // 600/min sustained
let { tokens = BURST, updated = now } = (await this.state.storage.get("b")) ?? {};
tokens = Math.min(BURST, tokens + ((now - updated) / 1000) * REFILL_PER_SEC);
const ok = tokens >= 1;
if (ok) tokens -= 1;
await this.state.storage.put("b", { tokens, updated: now });
return Response.json({ ok, retryAfter: ok ? 0 : Math.ceil((1 - tokens) / REFILL_PER_SEC) });
}
}
The comment in the middle carries the important insight: with immutable caching, the overwhelming majority of tile requests are answered at the edge and never reach the limiter at all. The limit therefore applies to origin-bound traffic, which is precisely the traffic worth protecting, and a reader browsing cached areas is never rate limited no matter how fast they pan.
Complementary Controls
Rate limiting alone is a blunt instrument, and three cheaper controls remove most of the need for it.
Referer allow-listing. A CDN rule permitting only your own origins stops casual embedding at zero cost. It is trivially forged and it is not a security control — but the traffic it stops is not adversarial, it is someone copying a URL.
Signed URLs with a short expiry. Appropriate when the tileset is genuinely restricted. It costs a signing step in the application and, critically, a query string — which fragments the cache key unless the CDN is configured to exclude the signature from it.
A separate, deliberately public tileset. Often the right answer. If some tiles may be public and others may not, splitting them is simpler and more robust than gating one endpoint two ways.
Interaction Effects
With caching. A 429 must never be cached. Return Cache-Control: no-store on it, or the CDN can serve the rejection to other clients from the same edge.
With the cache key. Anything that varies per client — a token in a query string, a per-user path — destroys the hit ratio. If the deployment needs both authentication and caching, sign a cookie or use a CDN feature that excludes the token from the key.
With monitoring. A rising 429 rate is worth alerting on in both directions: upward means something changed, and a limit that never fires may be set so high it protects nothing. Add it to the status-code mix.
With PMTiles. Range requests against one archive all share a path, so a per-path limit is useless. Limit per client, and remember that a cold PMTiles client legitimately makes several requests before its first tile.
Performance Impact
An edge-evaluated token bucket adds well under a millisecond and only on cache misses. The measurable cost is state: a per-IP bucket on a global CDN means a coordination point per client, which is why the implementation above uses a Durable Object rather than a global counter.
For most deployments the cheaper approximation is enough — a CDN’s built-in rate limiting rule, evaluated per edge location without global coordination. It permits roughly N times the configured rate for a client hitting N locations, which for the threat being addressed is entirely acceptable.
Common Mistakes
Tuning for API traffic. Sixty requests per minute is a normal pan. Any limit below a few hundred per minute breaks ordinary use.
Rate limiting before the cache. Throttles readers browsing warm regions, which is exactly the traffic that costs nothing to serve.
Caching the 429. Turns one client’s rejection into everyone’s.
Limiting by path rather than client. A single archive path or a hot tile is requested by everyone, and a per-path limit throttles them all together.
FAQ
Will rate limiting break MapLibre?
Not if the burst allowance is generous. MapLibre issues tile requests in parallel and does not retry aggressively on 429, so a client that hits the limit sees missing tiles until the bucket refills rather than an error state.
Should I return 429 or 503?
429 with Retry-After, which is what the status exists for and what a well-behaved client understands.
Does a public tile endpoint need authentication?
Only if the data is restricted. For an open basemap the realistic goal is bounding cost, not preventing access, and rate limiting plus a referer rule achieves that.
What about bandwidth rather than request count?
Byte-based limits are better aligned with cost when tile sizes vary widely, and most CDNs support them. The same burst-versus-sustained shape applies.
Related
- Tile Observability and Monitoring — the parent topic, and where
429fits in the status-code mix. - Serving PMTiles Through a Cloudflare Worker — the same edge runtime, doing the serving rather than the limiting.
- Cache-Control Headers for Immutable Vector Tiles — why most requests never reach the limiter.
- CORS Configuration for Cross-Origin Tile Requests — the other policy layer on a public endpoint.