Back in 2021 I watched a mid-size e-commerce origin flatline during a flash sale — not because of customer traffic, but because 140 CDN edges decided to revalidate 2MB product images at line rate simultaneously. The origin had a 10 Gbps uplink that looked generous on paper, but we saturated it in under 12 seconds and then spent the next 20 minutes dropping legitimate checkouts. That night taught me that controlling origin bandwidth isn’t optional, it’s what keeps your cache fill from turning into a self-inflicted DDoS.
When Every Edge Pulls at Full Speed, Origins Drown
By default, most edge proxies will pull from origin as fast as TCP allows. If the object is 50MB and the path supports it, you’ll see bursts well over 800 Mbps per connection. Multiply that by a few dozen edges refetching after a purge or a surge of cache misses, and your origin’s NIC, your transit bill, and your upstream buffers all pay the price. I’ve seen teams blame their origin application for timeouts when the real culprit was TCP incast and buffer exhaustion on a single top-of-rack switch.
Bandwidth throttling at the edge isn’t about slowing down end users — it’s about shaping the edge-to-origin leg. You’re still delivering from cache to the client at full speed, but you’re deliberately pacing how fast each edge fetches a miss. Without that rate control, you can’t provide any real isolation between tenants or protection for fragile origins. A well-tuned throttle also smooths out traffic, which makes Origin Connection Pooling Optimization: Reducing TCP Overhead at CDN Scale far more effective, since pooled connections aren’t constantly fighting for bursty bandwidth.
The mechanics are straightforward if you look at the HTTP layer. Per RFC 9110 and related specs documented in the HTTP Working Group Specifications, there’s no standard header that tells an edge how fast to pull — this is entirely a local traffic shaping decision on the CDN side. On Nginx-based edges we typically use limit_rate scoped only to the origin fetch location, on ATS we configure proxy.config.http.origin_max_speed, and on Envoy we apply a token-bucket filter on the origin cluster. The key is to never apply that limit to the client-facing response.
# Edge origin fetch throttling - applied ONLY to the origin peer
# Do not apply this to client-facing server{} blocks
upstream origin_backend {
server origin.example.com:443 max_fails=3 fail_timeout=10s;
keepalive 64;
}
server {
location @origin_fetch {
proxy_pass https://origin_backend;
# Allow burst for headers/small objects, then throttle
limit_rate_after 512k;
limit_rate 4m; # 4 MB/s per connection (~32 Mbps)
# Critical: per-connection limit prevents single edge from hogging origin
proxy_buffering on;
proxy_max_temp_file_size 0;
proxy_read_timeout 30s;
# Add pacing header for origin logging
proxy_set_header X-Edge-Throttle "32mbit";
}
}
Token Buckets and Per-Origin Rate Control in Practice
Most modern CDNs I’ve worked on don’t use a static limit_rate anymore. We use a token bucket per origin hostname, shared across all connections from a single PoP. Think of it as a bucket that refills at your allowed transfer rate — say, 500 Mbps — and each byte pulled from origin costs a token. If the bucket is empty, the edge pauses reading from the origin socket. This shapes traffic without dropping connections and it automatically handles bursts far better than a fixed per-connection cap.
I learned this the hard way when we tried simple per-connection throttling for a video customer. Each of our 80 connections was limited to 5 Mbps, which looked safe. But when a new season dropped, the edge opened 80 concurrent range requests for 4K segments and we still hit 400 Mbps of aggregate origin bandwidth per PoP — twice what their origin could sustain. Switching to a PoP-wide token bucket for that origin immediately fixed the spikes. It also made our retry behavior predictable, which is why this pairs so closely with Origin Retry Strategies for CDN Resilience: Backoff, Jitter, and Budget Limits; you don’t want throttled fetches to be mistaken for failures and retried aggressively.
You’ll need to decide where the bucket lives. Per-connection buckets are simplest but don’t cap aggregate usage. Per-PoP per-origin buckets are the sweet spot for most teams — they limit the blast radius of a single location while remaining simple to reason about. Global distributed buckets sound ideal but they require coordination between PoPs and add latency you don’t want on the fetch path. The guidance in web.dev Articles on prioritizing first-byte latency applies here too: if your throttle is too aggressive, you’ll inflate time-to-first-byte for cache misses, so measure both origin bandwidth and cache fill TTFB.
// Per-PoP token bucket for origin bandwidth throttling (Go-style pseudo)
type OriginThrottle struct {
bucket *TokenBucket // refillRate = 500 * 1024 * 1024 // 500 Mbps in bytes/sec
mu sync.Mutex
}
func (t *OriginThrottle) ThrottledRead(originConn net.Conn, buf []byte) (int, error) {
// Block until we have tokens for this read
needed := len(buf)
t.bucket.Wait(needed) // sleeps if bucket empty, paces transfer rate
n, err := originConn.Read(buf)
if err != nil {
return n, err
}
// Passive health signal - slow origin shouldn't trigger active probe failure
// See hybrid monitoring patterns for context
metrics.RecordOriginReadBytes(n)
return n, nil
}
// Init per origin: 500 Mbps refill, 50MB burst allowance
throttle := NewOriginThrottle(500*1024*1024/8, 50*1024*1024)
Choosing Transfer Rate Limits That Fit Your Origin Class
There’s no single magic number for origin bandwidth. A t3.medium on a 5 Gbps ENA, a 10 Gbps bare-metal fleet behind a load balancer, and a cloud object store all behave very differently under sustained pull. What I tell teams is to start from the origin’s sustainable egress, not its burst capability, then allocate about 60-70% of that to CDN refill and leave headroom for direct traffic, health checks, and replication. If you push throttling too low, you’ll see cache fill queue depth climb and stale-while-revalidate start to lag.
The other variable folks forget is concurrency. Your per-edge throttle multiplied by peak edge concurrency equals your real origin load. I always run a quick purge test in staging: purge a 10MB object, then request it from 50 PoPs at once with throttling enabled and watch the origin’s 95th percentile bandwidth and 5xx rate. That test, combined with data from Origin Health Monitoring Patterns: Active Probes, Passive Checks, and Hybrid Approaches, gives you a much clearer picture than any spreadsheet. Don’t chase the lowest possible origin bandwidth at the cost of user-visible latency — a 300ms increase in miss TTFB often hurts more than saving 20 Mbps.
Below is the baseline I use as a starting point for new origins. These aren’t rules, they’re conversation starters. Adjust the per-edge rate until your aggregate refill stays under target while keeping p95 cache fill under ~800ms for objects <5MB. For larger video or software artifacts, you’ll often accept lower per-connection rates with higher concurrency.
| Origin Class | Per-Edge Throttle | Aggregate Origin Bandwidth Target | p95 Cache Fill (8MB object) | Notes |
|---|---|---|---|---|
| Small VPS / Single VM (1-2 Gbps NIC) | 8 - 12 Mbps | 150 - 250 Mbps | 780 - 950 ms | Keep burst under 25MB; very sensitive to concurrent pulls |
| Medium Bare Metal / LB Fleet (10 Gbps) | 25 - 40 Mbps | 600 - 900 Mbps | 520 - 680 ms | Sweet spot for e-commerce; allows 30-40 concurrent fills |
| Object Storage (S3/GCS) + CDN | 50 - 80 Mbps | 1.2 - 2.0 Gbps | 430 - 560 ms | Higher throttle OK; origin scales horizontally |
| Auto-scaling Origin (Kubernetes, 5-20 pods) | 15 - 25 Mbps | 400 - 700 Mbps | 610 - 790 ms | Throttle prevents HPA thrashing during purge storms |
Putting Throttling Into Production: From Theory to Edge Config
You've picked a target number — say 2 Gbps for your origin shield — but that number's useless until 150+ PoPs actually respect it. The naive approach is a local token bucket on each edge server, refilling at a fixed rate. It works for single-box origins, but it falls apart at scale. If each of your 200 edges allows 50 Mbps, you'll burst to 10 Gbps the moment a large purge hits and they all pull at once. We've seen that 8-10x overshoot take down a 5 Gbps origin in under 12 seconds.
What holds up in production is a two-tier model. Tier one is a local bucket for micro-bursts — typically 50–100 MB of tokens per server, refilled at 20–30 MB/s. Tier two is a global coordinator in your control plane (Redis, or the CDN's own rate service) that aggregates usage every 1–2 seconds and pushes back throttling signals. You won't get perfect precision — expect 5–8% overshoot — but you'll stay within 10% of your origin's sustainable rate instead of 300% over it. Don't carve the limit evenly, either. Your top 10 PoPs like Ashburn, Frankfurt, and Singapore will drive 60–70% of cache fills. Give them 250–300 Mbps each and cap smaller PoPs at 40–60 Mbps. That leaves you ~20% headroom for revalidation storms without choking fill times.
Roll it out in shadow mode first. Log what would have been throttled for 24–48 hours before you actually inject delays. We've caught dozens of cases where an origin assumed to be 10 Gbps was actually behind a 1 Gbps NAT gateway or a WAF with a 500 Mbps license. And wire your throttling metrics right next to your timeout dashboards. When you're intentionally pacing a 150 MB video segment to 25 MB/s, your read timeout will fire if you haven't tuned it. If you haven't already, review your Origin Timeout Configuration Guide: Connect, Read, and Total Timeout Tuning — a throttled transfer that takes 6 seconds will trip a default 5s read timeout and create a retry loop you were trying to prevent.
# Example: Two-tier origin throttling (CDN shield / Nginx-style)
origin_throttle {
origin_group = "prod-api-origin-01"
global_limit = 2gbps # hard cap for the entire origin
burst_allowance = 400mb # global burst bucket
sync_interval = 1s # gossip to coordinator
per_pop_limit {
tier_large = 300mbps # top 10 PoPs
tier_default = 50mbps # remaining PoPs
local_bucket = 75mb
refill_rate = 25mbps
}
# Adaptive backoff - cut limits by 50% if origin struggles
adaptive_throttle {
trigger_p50_ttfb > 350ms for 30s
trigger_5xx_rate > 1% for 60s
action = halve_limits
recovery = 5m
}
on_throttle = delay_queue # options: delay_queue | serve_stale | reject_503
max_queue_time = 4s
}
Advanced Patterns and Troubleshooting When Throttling Bites Back
Static limits are fine until your origin's health changes. That's where adaptive throttling earns its keep. Instead of a fixed 2 Gbps, tie your bucket refill rate to origin signals. If p50 TTFB climbs from 80ms to over 350ms for 30 seconds, or your 5xx rate crosses 1%, automatically halve the global limit for 5 minutes. We've used this to survive database failovers where the origin's effective capacity dropped from 10 Gbps to 2 Gbps without anyone updating a config. Once TTFB recovers below 150ms, ramp back up in 10% steps every 60 seconds — don't snap back to 100% instantly or you'll thundering-herd the still-warm origin. This pairs best with collapsed forwarding and stale-while-revalidate so queued requests coalesce instead of stacking up.
The most common failure we see isn't throttling too little — it's throttling too aggressively and misdiagnosing it as an origin outage. Symptoms: cache fill times jump from 40ms for a 2 MB object to 500–800ms, your throttle queue depth sits above 4–5 seconds, and clients see `503 Origin Throttle Exceeded` or stale content while fresh fills are delayed. If your queue time consistently exceeds your Origin SSL Verification at CDN Edges: Preventing Man-in-the-Middle Attacks handshake budget plus origin latency, you're not protecting the origin — you're just shifting the bottleneck to the edge. Check `origin_bandwidth_mbps` vs. `throttle_delay_seconds` on the same graph. If bandwidth is flat at your cap while delay climbs, raise the cap 15–20% or add a shield PoP. If bandwidth is well under cap but fills are still slow, your bottleneck is likely TLS connection reuse or origin concurrency, not bandwidth.
Throttling also interacts badly with request hedging if you don't plan for it. Hedging — sending the same cache-miss to two origins and using the first response — is brilliant for cutting tail latency, but it doubles your origin bandwidth. If you've throttled your origin to 1 Gbps, a hedged rollout can silently double your pull rate to 2 Gbps at the worst moment. Our rule: never hedge throttled origins without accounting for it in the budget. If you must hedge, read our guide on Origin Request Hedging: Sending Parallel Requests to Cut Tail Latency and set `hedge_factor = 1.3x` into your throttle math, or restrict hedging to shield-only pulls. And keep origin keep-alive pools warm — re-establishing TLS for every throttled, paced transfer will add 80–120ms of handshake overhead that makes your throttling look slower than it is.
Frequently Asked Questions
Should I throttle per-edge or use a global limit?
Almost always global, with local enforcement. Per-edge limits alone can't prevent a coordinated burst — 100 edges each pulling at 100 Mbps is 10 Gbps. Use a global token bucket synced every 1–2 seconds and enforce it locally. Per-edge buckets are still useful as a second line of defense to smooth micro-bursts of 50–100 MB, but they shouldn't be your only control.
Will throttling increase cache miss latency for users?
Yes, intentionally — but it should be controlled. For small objects under 1–2 MB, the added delay is usually 10–30ms if you're paced correctly. For large objects like 50–150 MB video segments, throttling from 100 MB/s to 25 MB/s can add 1.5–4 seconds to fill time. Mitigate that with larger edge caches, longer TTLs, and serving stale-while-revalidate so only the first miss pays the throttling cost. If your p95 fill time jumps over 1s, your limit is too tight.
How does throttling work with auto-scaled origins in AWS/GCP?
Don't set a static cap. Tie your throttle to your auto-scaling group's current desired capacity. For example, base your limit on 70% of the aggregate NIC bandwidth of your current origin fleet — if you have 5 c6i.2xlarge instances at 12.5 Gbps each, set a ~40 Gbps global cap. Update it via API when the ASG scales. And add adaptive backoff based on CPU or 5xx signals, since a scale-out event takes 60–90 seconds during which your origin is still constrained.
What's the difference between throttling and concurrency limiting?
Bandwidth throttling limits MB/s, concurrency limiting limits simultaneous connections. You often need both. An origin might handle 5 Gbps but only 1,000 concurrent origin connections due to thread pools. You could hit the connection limit at just 800 Mbps with many small requests. Set bandwidth throttling for large-file origins and add a connection cap — e.g., 800 concurrent fetches per origin — for API origins with thousands of small JSON responses.