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.

Where a limit has to sit to separate readers from scrapersRequest rates per minute for an idle map, an actively browsing reader, a heavy multi-monitor session and a pyramid scraper, with a threshold between the third and fourth.REQUESTS PER MINUTEMap open, not interacting4/minActive browsing90/minHeavy session, large viewport260/minPyramid enumeration3,400/minlimit: 600/min
The gap between heavy legitimate use and enumeration is an order of magnitude, which is what makes a limit possible at all.

Production Command

At the edge, a token bucket with a generous burst and a moderate refill:

javascript
// 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.

Why cached requests are never rate limitedA cached tile request is answered at the edge before the limiter runs; only a miss reaches the limiter and then the origin.ClientEdge cacheLimiterGET a warm tile206/200 from cachelimiter never runsGET a cold tilemiss - consult the limiter429 if the bucket is emptyonly cold traffic is throttled
Placing the limiter after the cache lookup means normal browsing of warm regions is unaffected, and only enumeration of cold ones is throttled.

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.

Four ways to protect a public tile endpoint, comparedRate limiting, referer allow-listing, signed URLs and a separate public tileset compared on what each stops, its cache impact and its operational cost.FOUR CONTROLSstopscache impactcostRate limitingEnumerationNoneEdge stateReferer allow-listCasual embeddingNoneOne CDN ruleSigned URLsUnauthorised accessFragments the keyA signing stepA separate public tilesetExposure of privatedataNoneOne more build
Signed URLs are the only row that fragments the cache key, which is why they cost far more than they appear to.

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.