Rate Limiting as a Reliability Tool: Protecting CDN Infrastructure from Internal Overload

Back in 2019 I watched our own purging system take down a whole PoP in Ashburn. It wasn't an attack — it was a routine cache invalidation that fanned out to 40 edge nodes at once, each hammering the origin shield with unconditional revalidation. We'd spent months hardening against external DDoS, but we hadn't considered that our internal services could generate the same kind of thundering herd. That's when I stopped thinking of rate limiting as a security feature and started treating it as a reliability primitive.

When Your Own Services Are the Biggest Risk

Most engineers hear "rate limiting" and picture abusive clients or bot scrapers. That's external protection, and it matters, but for a CDN the more insidious overload often comes from inside. Health checks that get too enthusiastic, log shippers retrying on failure, configuration management that pushes to every node simultaneously, or a microservice that falls into a tight retry loop — I've seen all of them create more sustained load than any customer ever has. Unlike external traffic, internal load bypasses your WAF and edge filters because it's trusted by default.

The core idea of SRE Practices for CDN: Applying Site Reliability Engineering to Content Delivery is that you design for failure from within, not just from without. For internal protection, that means every internal caller gets a budget. We don't trust services to behave, we enforce that they can't misbehave at scale. My team learned this the hard way after a video packaging service started issuing range requests at 20,000 per second when its local cache corrupted. It wasn't malicious; it was just broken. Without per-service limits at the shield layer, that broken loop propagated instantly to our origin storage cluster and pushed p99 latency from 80ms to over 4 seconds.

Our fix wasn't to fix the bug faster — bugs are inevitable. We built CDN self-defense directly into the data path. Every internal service authenticates with a service token, and every hop enforces its own limit before forwarding. This is classic load management, but applied service-to-service. The pattern aligns closely with how the HTTP Working Group Specifications define 429 Too Many Requests and Retry-After — we use those semantics internally, not just for clients. If you're not using backpressure signals between your own components, you're relying on hope.

# Shield layer: per-service token bucket in NGINX / OpenResty
# Protects origin storage from internal fan-out
limit_req_zone $http_x_service_token zone=svc_tokens:20m rate=500r/s;
limit_req_zone $http_x_service_token zone=svc_burst:20m rate=1000r/s burst=2000 nodelay;

upstream origin_storage {
    server origin-internal.example.net:443 max_fails=3 fail_timeout=10s;
}

server {
    listen 8080;
    location /internal/revalidate {
        # Strict limit for cache purge/revalidate path
        limit_req zone=svc_tokens burst=500 nodelay;
        limit_req_status 429;
        add_header Retry-After 2 always;
        
        # Log who is being throttled
        access_log /var/log/nginx/internal_limit.log svc_limit if=$limit_req_status;
        proxy_pass https://origin_storage;
    }
}

Building Self-Defense Into Every Layer

You can't put a single rate limiter at the origin and call it done. Effective rate limiting reliability requires defense in depth. We enforce limits at three places: the caller (client-side throttling), the edge (admission control), and the origin shield (hard protection). The caller-side limiter is the most polite — it uses a local token bucket so the service slows itself before it ever hits the network. The edge is where we do coarse-grained global limiting, and the shield is the last line that says "no matter who you are, you don't get more than this."

I prefer token buckets over fixed windows for internal use because they handle bursts more gracefully. CDNs are bursty by nature — a purge or a config reload *should* allow a short spike, just not a sustained flood. A fixed window of 500 req/s will reject 501 requests in the same second even if the system was idle for the previous 10 seconds. A token bucket with a burst of 2000 lets that legitimate spike through while still capping the average. When we switched our cache-fill path from a leaky bucket to a token bucket with a 2-second burst allowance, our false-positive throttling on deploys dropped by 70% without changing the sustained protection.

The other piece people miss is observability. A rate limiter that silently drops is a debugging nightmare. Every 429 we return internally includes a structured header — X-RateLimit-Scope: svc-purge, X-RateLimit-Reason: shield-bucket-exhausted — and we emit a counter on every throttle decision. Those metrics feed directly into our CDN Error Budget Management: Balancing Reliability with Feature Velocity dashboards. If a service starts burning its error budget because it's getting throttled, we know long before it pages someone. And if you're still manually tuning limits by editing configs, take a look at CDN Toil Reduction Through Automation: Eliminating Repetitive Operations Work — we've automated limit adjustments based on that telemetry, so we're not hand-editing NGINX at 3am anymore.

-- Distributed token bucket with Redis for global view (Lua / OpenResty)
-- Used at edge for per-service global rate limiting

local redis = require "resty.redis"
local red = redis:new()

local SERVICE = ngx.var.http_x_service_token
local KEY = "ratelimit:svc:" .. SERVICE
local RATE = 5000  -- 5k r/s globally
local BURST = 8000
local INTERVAL = 1 -- per second

-- SCRIPT: atomic token bucket (tokens, last_refill)
local script = [[
  local key = KEYS[1]
  local rate = tonumber(ARGV[1])
  local burst = tonumber(ARGV[2])
  local now = tonumber(ARGV[3])
  local requested = tonumber(ARGV[4])
  local data = redis.call('HMGET', key, 'tokens', 'ts')
  local tokens = tonumber(data[1]) or burst
  local last = tonumber(data[2]) or now
  local delta = math.max(0, now - last)
  tokens = math.min(burst, tokens + delta * rate)
  local allowed = tokens >= requested
  if allowed then tokens = tokens - requested end
  redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
  redis.call('EXPIRE', key, 2)
  return {allowed and 1 or 0, tokens}
]]

local res, err = red:eval(script, 1, KEY, RATE, BURST, ngx.now(), 1)
if res[1] == 0 then
  ngx.header["Retry-After"] = "1"
  ngx.header["X-RateLimit-Remaining"] = res[2]
  return ngx.exit(429)
end

Tuning Limits Without Breaking Legitimate Traffic

Setting the number is the hardest part. Too low and you throttle your own deployments; too high and you don't actually protect anything. We don't guess — we measure from production. For two weeks we ran limiters in log-only mode (we called it shadow mode), recording what *would* have been blocked without actually blocking. That gave us a histogram of per-service demand. From there we set the limit at p95 plus 40% headroom, with bursts sized to cover our largest known legitimate fan-out. The web.dev Articles on optimizing INP and load are a good reminder here: throttling shouldn't just protect the server, it shouldn't create user-visible latency either.

Don't set and forget. Traffic patterns shift after every feature launch, and limits that were perfect last quarter will be wrong next quarter. We review shadow-mode data weekly and treat limit changes like any other config change — canaried, rolled out gradually, and automatically rolled back if origin error rates climb. One mistake I made early on was tying limits to host count. When we doubled our edge footprint, the per-host limit stayed the same, so the global allowed rate doubled and we lost our protection. Now all our internal limits are expressed as global rates, not per-node rates, and we use a shared store or consistent hashing to enforce them.

Below is how we currently think about choosing a strategy for different internal paths. Numbers are from our production shield layer over the last 90 days — your mileage will vary, but the trade-offs hold.

Internal Path Algorithm Sustained Limit (global) Burst Allowance p95 Overhead When Throttling
Cache Revalidation / Purge Token Bucket (Redis) 5,000 req/s 8,000 tokens 12ms + Retry-After 1s
Origin Fill (range requests) Leaky Bucket (local) 12,000 req/s 1,000 queue depth 8ms queuing delay
Config Distribution Fixed Window (1s) 800 req/s No burst (strict) 45ms (jittered backoff)
Log & Telemetry Ingest Adaptive Concurrency ~15,000 req/s (auto-tunes) Dynamic 22ms + 429 with exponential backoff

Implementing Rate Limits That Don't Fall Over When You Need Them

Knowing you need rate limiting and actually enforcing it at CDN scale are two very different problems. The biggest mistake I've seen teams make is implementing limits in the application layer only — by the time a request hits your origin app server, you've already burned TCP connections, TLS handshakes, and load balancer capacity. Effective limits have to live as close to the edge as possible, and they have to be cheap to evaluate. We're talking single-digit microseconds per decision, not a Redis lookup on every request.

At the PoP level, I push for a two-tier model: a local token bucket for instantaneous protection and a distributed counter for cluster-wide fairness. The local bucket handles the thundering herd — say, 2,000 RPS per cache node with a burst of 400 — without any cross-node coordination. The distributed layer, backed by something like a sliding window counter in our shared memory or via Envoy's global rate limiting service, enforces the per-service quota across the entire PoP. That way, if one node gets hammered with 5,000 RPS from a retrying microservice, it sheds load immediately instead of waiting for a consensus round-trip. You'll want to return 429s or 503s with a proper Retry-After header, not just drop the connection.

This is where a lot of automation goes sideways. We've all seen a deploy script retry aggressively when it gets a 5xx and turn a small blip into a full outage. It's the classic The CDN Automation Reliability Paradox: When Automation Causes More Outages — your tooling meant to ensure reliability becomes the source of overload. Your rate limiter has to be smarter than your automation. Configure your internal clients with exponential backoff and jitter, and make sure your limiter distinguishes between user traffic and service-to-service calls by header or mTLS identity.

# Envoy local + global rate limit example for an internal origin
# Local: protects the single proxy instance. Global: protects the origin cluster.
local_ratelimit:
  stat_prefix: origin_image_api_local
  token_bucket:
    max_tokens: 2000
    tokens_per_fill: 2000
    fill_interval: 1s
  filter_enabled:
    runtime_key: local_ratelimit_enabled
    default_value: 100
  filter_enforced:
    runtime_key: local_ratelimit_enforced
    default_value: 100
  response_headers_to_add:
    - header: { key: "x-ratelimit-reason", value: "local-bucket" }
    - header: { key: "retry-after", value: "1" }

# Global rate limit service (gRPC) - per-service quota
rate_limits:
  - actions:
    - request_headers: { header_name: ":authority", descriptor_key: "destination_cluster" }
    - request_headers: { header_name: "x-service-id", descriptor_key: "source_service" }
    - generic_key: { descriptor_value: "origin_protection" }
# Descriptors configured on rate limit service:
# destination_cluster: image-origin.prod -> 15000 rps
# source_service: purge-orchestrator -> 500 rps max to image-origin

Advanced Patterns: Adaptive Limits, Prioritization, and Noisy-Neighbor Isolation

Static limits will save you 80% of the time, but they can't adapt when legitimate traffic naturally doubles during a live event. That's where adaptive concurrency control comes in. Instead of a fixed RPS number, we track the origin's p95 latency and error rate. If p95 latency climbs from 45ms to 120ms over a 30-second window, or if the origin starts returning 10% 503s, we automatically dial the limit down by 30% and then slowly ramp back up using additive increase. Netflix's concurrency-limits-java and similar Vegas-style algorithms do this well — they treat outstanding requests, not just RPS, as the signal. We've found inflight request limiting is far more accurate for heterogeneous origins where one request might take 20ms and another 2 seconds.

You also need prioritization. Not all internal traffic is equal. A cache purge request from your control plane can't be treated the same as a background log-shipper retrying. We implement at least three priority queues: Critical (health checks, purge invalidations), Standard (user-facing API calls, cache fills), and Best-Effort (prefetch, analytics, image optimization warmups). When we hit 85% of capacity, Best-Effort gets 429s first. At 95%, Standard starts shedding. Critical never gets throttled unless the origin is completely down — and if that's happening, you've got bigger problems. This prevents a low-priority batch job from starving customer traffic, which I've seen happen more times than I'd like to admit.

Finally, make your rate limiting observable and executable. Don't just log that you throttled — emit metrics per (source_service, destination, response_code) and alert on sustained throttling. If your purge-orchestrator is getting throttled at 450 RPS for more than 5 minutes, that's not normal, that's a bug. Your on-call shouldn't be hand-editing NGINX configs at 2 a.m. to fix it. A good CDN Runbook Automation: Converting Manual Procedures into Executable Scripts lets you safely raise or lower limits via a version-controlled, audited workflow — not SSH and vim. And when you do get it wrong, capture it. Every throttling incident where legitimate traffic was dropped should feed directly into your CDN Postmortem Analysis: Learning from Incidents to Prevent Recurrence so you tune the limits, not just apologize for them.

Frequently Asked Questions

Should we rate limit by IP, by service, or by API key for internal traffic?

By service identity, every time. IP-based limiting is useless inside a CDN where everything's behind NAT or comes from a handful of Kubernetes egress IPs. Use mTLS client certificates or a required x-service-id header that's injected by your service mesh sidecar and can't be spoofed by user traffic. You can layer API keys on top for external partners, but for internal overload, you want to know *which* service is misbehaving — e.g., "recommender-v3 is doing 12k RPS to origin-search" — not just that 10.0.4.12 is hot.

What's a safe starting point if we've never rate limited internal origins before?

Don't guess. Baseline first. For two weeks, run your limiter in shadow/dry-run mode — log what *would* have been throttled without actually returning 429s. Look at your p99 and p99.9 RPS per service-to-origin pair. Then set your initial limit at 150-200% of your normal p99 peak. For most of our origins, that lands around 3x the average traffic. Set a generous burst (50-100% of the limit) to handle natural spikes. You can tighten it by 10-15% each week as you gain confidence. It's better to start permissive and learn than to start strict and break prod on day one.

How do we prevent retry storms from amplifying a rate limit?

You have to fix the client, not just the server. Enforce three things on every internal client: 1) Exponential backoff with full jitter (e.g., base 100ms, max 10s), 2) Circuit breakers that open after 50% errors in a 10-second window and stay open for 30 seconds, and 3) Respect for Retry-After headers. Without these, a 429 just becomes a delayed retry that hits you again 500ms later. We also add a small amount of load shedding on the client side — if the client itself has more than 1,000 queued requests to a single origin, it should fail fast locally instead of queuing indefinitely.

Will rate limiting hide the real root cause of origin overload?

It can if you let it. Rate limiting is a reliability tool, not a fix for a slow origin or a runaway crawler. That's why observability is non-negotiable. Dashboard your throttling rate, origin latency, and origin CPU together. If you're throttling 20% of traffic every day at 3 p.m., you don't have a rate limiting success — you have a capacity planning failure. Use the limiter to keep the system stable while you fix the underlying issue, whether that's adding origin capacity, fixing an N+1 query, or patching that service that's polling every 50ms instead of every 5 seconds.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker