Back in November 2019, we’d just signed a new streaming customer who promised us a “modest” launch. Their modest launch turned into 800 Gbps of extra egress overnight, and our Frankfurt PoP hit 94% link utilization before breakfast. We didn’t lose traffic, but we spent the next six hours shedding non-critical purge jobs and begging our transit provider for an emergency upgrade. That’s when I learned that capacity planning isn’t about pretty graphs — it’s about not getting woken up at 3 a.m.
Why Bandwidth Forecasting Fails Past a Few Terabits
When you’re small, bandwidth forecasting feels straightforward. You take last month’s 95th percentile, add 30%, and call it a day. That works until you’ve got dozens of PoPs, mixed traffic profiles, and customers who don’t warn you before they push a 40GB game patch. At scale, your forecast has to separate baseline growth from one-off events, or you’ll either overbuild by millions or get caught short.
I’ve found the most useful split is to model three layers separately: organic growth, seasonal patterns, and event-driven spikes. Organic growth is your steady 2-4% month-over-month increase from existing customers. Seasonal is the stuff you can set your watch to — think Black Friday, the Super Bowl, or Apple’s annual OS release that always hammers your cache hit ratio. Event spikes are the unpredictable ones, like a viral live stream or an unannounced software update. If you lump them together, your model will overreact to every outlier. We keep a separate event ledger and exclude those days from the baseline trend, then add them back as a risk buffer.
You can’t do this without clean telemetry. We sample sFlow and SNMP counters every 30 seconds, then aggregate to 5-minute 95th percentile windows per PoP and per backbone link. Relying on daily averages hides the peaks that actually hurt you. And don’t forget that HTTP semantics matter here — cacheable traffic defined by the HTTP Working Group Specifications behaves very differently from uncacheable API or dynamic video manifest traffic. For performance implications on origin offload, the web.dev Articles on caching and delivery are a good sanity check on what should even hit your edge in the first place.
# Simple bandwidth forecast with seasonality and trend separation
import pandas as pd
def forecast_bandwidth(df, growth_rate=0.035, seasonal_factor=1.18):
# df: daily 95th percentile Gbps per PoP, with 'date' and 'gbps'
# 1. Remove known event days from trend calculation
baseline = df[~df['is_event_day']].copy()
baseline['trend'] = baseline['gbps'].rolling(30).mean()
# 2. Calculate organic monthly growth (exponential)
last_trend = baseline['trend'].iloc[-1]
months_ahead = 6
organic = last_trend * ((1 + growth_rate) ** months_ahead)
# 3. Apply seasonal multiplier for next peak period
forecast_peak = organic * seasonal_factor
# 4. Add headroom buffer (N+1 + burst)
required_capacity = forecast_peak * 1.4 # 40% headroom
return {
"organic_forecast_gbps": round(organic, 2),
"seasonal_peak_gbps": round(forecast_peak, 2),
"required_capacity_gbps": round(required_capacity, 2)
}
# Example: 9.2 Tbps baseline -> 11.1 Tbps organic -> 13.1 Tbps seasonal -> 18.3 Tbps required
Forecasting Compute Isn’t Just About Bandwidth
Newer engineers often think CDN scaling is purely a bandwidth problem. It isn’t. I’ve seen PoPs with plenty of 100G ports go down because the servers ran out of CPU on TLS handshakes during a Ticketmaster-style on-sale. Bandwidth gets you the bits into the building; compute is what lets you serve them. For every 10 Gbps of edge traffic, you need to model connections per second, TLS handshakes, header decompression, WAF rule evaluation, and compression work.
Our capacity model breaks compute into three buckets. First, request-rate capacity: how many requests per second a core can handle at your p95 object size. Second, memory footprint: hot object working set, connection table, and TLS session cache. Third, storage IOPS: especially if you’re doing SSD caching for mid-tier or origin shield. A server that can push 80 Gbps of large-file video might only manage 25 Gbps of small API responses because you’re bottlenecked on CPU, not NIC. That’s why we stopped sizing PoPs by “Gbps per rack” and started sizing by the tightest of those three constraints for that PoP’s traffic mix.
Network design ties directly into this. How you spread traffic across servers and links changes your per-node requirements. If you’re using ECMP Routing in CDN Networks: Equal-Cost Multi-Path Traffic Distribution inside the PoP, you’ll get excellent distribution but you have to account for hash polarization under failure. For backbone capacity between PoPs, many teams still rely on MPLS in CDN Backbone Networks: Label Switching for Predictable Performance to pin predictable paths and reserve bandwidth for cache fill. And when we need finer-grained control for overload avoidance, we’ve shifted some regions to Segment Routing for CDN Traffic Engineering: Source-Routed Path Selection so we can steer fill traffic away from hot edges without reconfiguring the whole IGP. Your compute forecast has to reflect which of those you’re running, because they change where the work lands.
// Per-server usable capacity calculation (Go-style pseudocode)
type ServerProfile struct {
NICCapacityGbps float64 // e.g., 2x25G = 50
MaxRPSPerCore int // measured: 18k rps/core for 32KB objects
Cores int
UsableCPULimit float64 // we cap at 65% for burst headroom
MemoryGB int
HotSetPerGbpsGB float64 // e.g., 4.2 GB per 10 Gbps for our workload
}
func UsableCapacity(s ServerProfile) (gbps float64, rps int) {
// Bandwidth limited
bwLimit := s.NICCapacityGbps * 0.75 // leave 25% for retransmits/fill
// CPU / request rate limited
cpuRPS := int(float64(s.Cores*s.MaxRPSPerCore) * s.UsableCPULimit)
// Convert RPS to Gbps using average object size for this PoP
avgObjectSizeKB := 85.0
rpsToGbps := float64(cpuRPS) * avgObjectSizeKB * 8 / 1e6
// Memory limited
memGbps := float64(s.MemoryGB) / s.HotSetPerGbpsGB * 10
// Usable is the minimum of the three
gbps = math.Min(bwLimit, math.Min(rpsToGbps, memGbps))
return gbps, cpuRPS
}
// Example: 32 cores, 128GB RAM -> bwLimit 37.5, rpsToGbps ~31.8, memGbps ~305
// Result: CPU is the limiter at ~31.8 Gbps per node for this traffic mix
Setting Headroom Thresholds and Knowing When to Build
Forecasting is pointless if you don’t have a hard rule for when to order hardware and turn up capacity. We learned this the hard way when our procurement cycle was 12 weeks and our forecast only looked 8 weeks out. Now we plan on a 26-week horizon and trigger builds based on headroom, not absolute utilization. It’s a subtle difference, but it keeps you from chasing spikes that have already passed.
Our rule for edge PoPs is simple: we must never exceed 60% sustained utilization on any single link or 65% CPU on any server cluster during peak 95th percentile, and we must maintain N+1 redundancy for both network and compute. That means if a PoP has four routers, it has to handle peak load with three. If it has 20 cache nodes, it has to survive with 19. When our forecast shows we’ll breach either of those thresholds within 90 days, we open a build ticket. Backbone links are tighter — we alarm at 50% because a single fiber cut shouldn’t push you over 80% on the remaining path.
We review this weekly with a capacity burn-down sheet that every PoP owner can read in 30 seconds. It’s not fancy, but it’s saved us more times than any machine learning model. Below is a simplified version of what we track for our top regions. Notice how the “trigger” column isn’t just “are we full today” — it’s “when does our forecast say we’ll run out of N+1 headroom.” That distinction is what turns network planning from reactive firefighting into actual CDN scaling strategy.
| PoP / Region | Current Peak (95th) | Installed Capacity | Forecast 6-Month Peak | Headroom Status & Trigger |
|---|---|---|---|---|
| Frankfurt (FRA) | 11.4 Tbps | 18.0 Tbps | 14.8 Tbps (+29.8%) | 36% headroom — Build triggered (hits 65% in 11 weeks) |
| Ashburn (IAD) | 9.1 Tbps | 16.0 Tbps | 10.6 Tbps (+16.5%) | 43% headroom — Monitor, next review 4 weeks |
| Singapore (SIN) | 6.8 Tbps | 10.0 Tbps | 8.9 Tbps (+30.8%) | 32% headroom — Urgent build, N+1 at risk in 7 weeks |
| São Paulo (GRU) | 3.2 Tbps | 6.0 Tbps | 4.1 Tbps (+28.1%) | 46% headroom — Healthy, no action |
From Forecast to Execution: Building Your Capacity Pipeline
Forecasts are useless if they don't turn into purchase orders and provisioning tickets on time. We've learned the hard way that you can't treat bandwidth and compute forecasting as a quarterly spreadsheet exercise — it has to be a continuous pipeline with clear lead times baked in. For most Tier 1 CDNs, the timeline looks brutal: 45-60 days to procure, rack, and burn in edge servers, 90-120 days for new transit or peering capacity in a congested market, and up to 6 months for a new PoP build. If your forecast only looks 30 days out, you're already late.
What actually works is a per-PoP, per-service model that runs weekly. Don't just forecast global Tbps — break it down. We model each PoP independently as Required Capacity = (p95 Bandwidth / Target Utilization) + Burst Buffer and Compute Demand = Requests/sec * Avg. CPU-ms per Request * Origin Miss Multiplier. That origin miss multiplier is critical. A PoP serving 800 Gbps of cacheable video at 95% hit ratio looks light, but flip that to 300 Gbps of uncacheable API traffic and you'll melt your CPUs long before you fill the pipes. This is where Network Function Virtualization in CDN Infrastructure: Virtual Appliances at Scale changes the math — when your WAF, image optimization, and TLS termination are virtualized, you can shift compute headroom dynamically instead of overprovisioning fixed-function boxes.
Here's the lightweight model we use to flag build triggers automatically. It compares forecasted peak against provisioned capacity after accounting for N+1 redundancy, not raw installed capacity. If you're forecasting off installed capacity without deducting your failure domain, you're fooling yourself.
# capacity_trigger.py - weekly capacity check per PoP
import pandas as pd
HEADROOM_TARGET = 0.35 # 35% headroom target for edge, 25% for backbone
N_PLUS_ONE_FACTOR = 0.85 # usable capacity with one chassis/rack offline
def check_build_trigger(pop_forecast_p95_gbps, provisioned_gbps, cpu_forecast_cores, provisioned_cores):
usable_bw = provisioned_gbps * N_PLUS_ONE_FACTOR
usable_cpu = provisioned_cores * N_PLUS_ONE_FACTOR
bw_util = pop_forecast_p95_gbps / usable_bw
# Trigger if forecast pushes us below headroom within lead time (60 days)
bw_trigger = bw_util > (1 - HEADROOM_TARGET)
# Compute bottleneck check: assumes 65% max sustainable CPU for p95 latency SLA
cpu_util = cpu_forecast_cores / usable_cpu
cpu_trigger = cpu_util > 0.65
if bw_trigger or cpu_trigger:
return f"BUILD ALERT: BW util {bw_util:.1%} | CPU util {cpu_util:.1%} - initiate procurement"
return f"OK: BW util {bw_util:.1%} | CPU util {cpu_util:.1%}"
# Example: PoP forecast to hit 1.8 Tbps next month on 2.4 Tbps provisioned
print(check_build_trigger(1800, 2400, 8500, 12000))
# -> BUILD ALERT: BW util 88.2% | CPU util 83.3% - initiate procurement
Advanced Patterns and Troubleshooting When Forecasts Break
Even the best model breaks, and it usually breaks on the same three things: hit ratio collapse, anycast drift, and correlated failures. We once saw a major software update drop our edge hit ratio from 92% to 78% in 40 minutes — bandwidth barely moved, but origin fetch and CPU for TLS handshakes spiked 4.2x. Your forecast didn't fail; your assumption did. That's why we now forecast compute on two parallel tracks: one for edge-served (cache hit) and one for origin-bound (miss + dynamic). If you don't separate them, you'll never see that cliff coming. Programmability helps here — with Software-Defined Networking for CDN: Programmable Traffic Control you can shed or steer traffic away from a hot PoP in under 30 seconds, buying you time that static BGP tweaks just can't.
The second killer is topology blindness. A lot of teams forecast PoP capacity correctly but forget the backbone. You can have 40% headroom at the edge and still drop packets because your backbone links between regions are running at 85%+ on peak evenings. We've seen this especially with hierarchical designs where parent caches funnel misses. Before any big build cycle, we run a failure simulation: what happens if we lose the entire Dallas or Frankfurt PoP? Does anycast re-route 1.2 Tbps to Chicago and Amsterdam, and can those PoPs plus the interconnects absorb it? If not, you don't have N+1, you have N. Reviewing your CDN Backbone Topology Design: Ring, Mesh, and Hierarchical Architectures isn't academic — it directly dictates how much spare capacity you actually need to hold in the mesh versus at the edge.
When you're troubleshooting a surprise, stop looking at averages. Pull 1-minute p95 and p99 for bandwidth and CPU per rack, not per PoP. We've found bottlenecks hiding where a single rack was pinning at 94% CPU while the PoP average looked like a healthy 48%. Check three things in order: 1) Is the NIC/packet-per-second limit hitting before bandwidth? (common with small object / API traffic - 40G NICs cap around 28-30 Mpps), 2) Is NUMA imbalance or kernel network stack saturation causing softIRQ stalls? and 3) Did a recent config push increase CPU cost per request? A 2ms increase in WAF inspection time doesn't sound like much until you multiply it by 800k RPS.
Frequently Asked Questions
What's a realistic headroom target? 30% feels wasteful, 15% feels scary.
It depends on your failure domain and build speed. For edge PoPs, we run 35-40% headroom on usable (N+1) capacity — meaning if you lose your biggest chassis or rack, you still have that headroom. For backbone and interconnects, you can run tighter at 25-30% because you can traffic-engineer faster with SDN. Anything under 20% at the edge and a single viral event or PoP failure will push you into packet loss. The cost of 15% extra servers is way cheaper than violating an enterprise SLA.
Should we forecast bandwidth and compute together or separately?
Always separately, then correlate them. Bandwidth is driven by bytes and bitrate, compute is driven by requests/sec, TLS handshakes, and origin misses. We forecast Tbps for network builds and kRPS + CPU-cores for server builds. They diverge constantly — think 4K video (high bandwidth, low compute) versus API acceleration or edge compute (low bandwidth, very high compute). If you forecast one from the other with a fixed ratio, you'll be wrong by 30-50% within a quarter.
How do you plan for unpredictable viral events that blow past any forecast?
You don't forecast virality, you build absorption capacity for it. We keep a "burst pool" — 10-15% of global capacity kept warm but not assigned to baseline traffic, plus an anycast overprovision of ~20% per region. Combined with autoscaling for virtualized functions and the ability to degrade gracefully (serve slightly stale, lower image quality, or throttle non-critical prefetch), you can absorb a 2-3x spike for 2-4 hours. After that, it's a capacity incident, not a forecasting error.
Why did we add 400 Gbps of capacity but CPU is still saturated?
Classic symptom of a compute-bound workload. You added network headroom but your requests are small, uncacheable, or heavy on TLS/WAF/edge logic. Check your average object size — if it's under 30KB, you're likely packets-per-second or CPU-bound, not bandwidth-bound. Also check origin offload: if your hit ratio dropped 5 points, that 400 Gbps of edge capacity just created more origin fetch work. Scale cores and optimize CPU-ms per request before you add more pipes.