Back in November 2019, I watched our edge traffic jump 340% in under six minutes during a sneaker drop we hadn't been warned about. Our autoscaler was still deciding whether to add nodes while origin response times blew past 4 seconds and the cache fill queue backed up to 90k requests. That night rewrote how our team thinks about traffic prediction — not as a nice-to-have dashboard, but as the core system that keeps CDN capacity ahead of demand instead of chasing it.
Why Static Thresholds Collapse When Traffic Goes Vertical
For years, most teams I've worked with relied on the same pattern: set an alert at 80% of provisioned capacity, add more boxes when it fires, repeat. It works fine for slow, diurnal growth. It completely falls apart for true peaks. By the time your 5-minute moving average crosses that threshold, you're already queuing. I’ve seen that delay cost us 12 minutes of degraded performance during a regional sports final — the alert fired at 19:03, but the traffic inflection started at 18:51 and our p95 latency had already doubled.
The problem isn’t just velocity, it’s context. CDN traffic isn’t a single time series; it’s a superposition of daily habits, weekly seasonality, content release calendars, and chaotic external events. A naive threshold can’t tell the difference between a normal 9pm binge-watching ramp and the first 60 seconds of a viral live stream. To do that you need history at the edge, not just aggregated counters. That’s why any serious demand forecasting effort starts with proper telemetry. If you haven’t already, get your CDN Real-Time Log Streaming Architecture: From Edge Events to Analytics in Seconds right first — without sub-10-second event latency, your forecast will always be looking in the rearview mirror.
We rebuilt our pipeline around CDN Log Aggregation Architecture: Collecting Billions of Events Without Dropping Data principles: sharded Kafka ingest at the PoP, exactly-once delivery into ClickHouse, and materialized views that continuously roll up request rates per PoP, per status class, and per content type. That gives us the clean, high-cardinality series we actually need to train on. The query below is a simplified version of what we run every minute to generate features for our forecaster — it builds lag, rolling, and seasonality features directly in SQL so we don’t have to reprocess raw logs in Python every time.
-- Feature generation for 5-min traffic prediction (ClickHouse)
WITH 5 * 60 AS interval_sec
SELECT
toStartOfInterval(timestamp, INTERVAL 5 MINUTE) AS t,
pop_id,
-- base signal
count() / interval_sec AS rps,
-- lags: what happened 1h and 1d ago
lagInFrame(rps) OVER (PARTITION BY pop_id ORDER BY t ROWS BETWEEN 12 PRECEDING AND 12 PRECEDING) AS rps_lag_1h,
lagInFrame(rps) OVER (PARTITION BY pop_id ORDER BY t ROWS BETWEEN 288 PRECEDING AND 288 PRECEDING) AS rps_lag_1d,
-- rolling stats
avg(rps) OVER (PARTITION BY pop_id ORDER BY t ROWS BETWEEN 11 PRECEDING AND CURRENT ROW) AS rps_ma_1h,
stddevPop(rps) OVER (PARTITION BY pop_id ORDER BY t ROWS BETWEEN 35 PRECEDING AND CURRENT ROW) AS rps_volatility_3h,
-- seasonality encodings
toHour(t) AS hour_of_day,
toDayOfWeek(t) AS day_of_week,
-- cache pressure signal per HTTP semantics (see https://httpwg.org/specs/)
avgIf(cache_status = 'MISS', 1, 0) AS miss_rate
FROM cdn.edge_logs
WHERE timestamp >= now() - INTERVAL 30 DAY
GROUP BY t, pop_id
HAVING rps > 0
ORDER BY t DESC;
Designing a Demand Forecasting Pipeline That Survives Production
When I first tried to model this stuff, I reached for ARIMA because it was familiar. It was also useless. Classical univariate models don’t handle the mix of strong multi-seasonality (daily, weekly) plus bursty, event-driven spikes that define CDN workloads. We’ve had far better luck with gradient-boosted trees for horizons under 2 hours and a hybrid Prophet + XGBoost residual model for 24-72 hour peak planning. The tree models are boring, fast to retrain, and they tolerate missing data and categorical features like PoP region or content category without a lot of fuss. For the longer horizon, Prophet captures the Fourier seasonality and holiday effects cleanly, and we let XGBoost clean up the residuals using real-time signals like active concurrent streams and origin error rates.
The features that actually moved the needle for us weren’t the ones I expected. Raw RPS matters, but rate-of-change, miss rate acceleration, and geographic entropy matter more. For example, a sudden drop in cache hit ratio paired with a rising request rate from a single AS often precedes an origin-thundering herd 3-5 minutes later — that’s a signal you can’t get from RPS alone. We also feed in scheduled event calendars (sports, releases) as binary regressors and use web performance hints like Server-Timing and early hints data described in web.dev Articles to distinguish legitimate traffic growth from retry storms. Don’t over-collect, though. We learned to be aggressive with CDN Log Sampling Strategies: Reducing Storage Costs Without Losing Signal for non-critical paths; you don’t need 100% sampling to forecast aggregate demand accurately, and storing full fidelity for 90 days will kill your budget.
Validation is where most teams fool themselves. Don’t evaluate your model on a random train/test split. Backtest it on rolling windows that include real peaks you’ve lived through. I keep a “horror reel” of 17 historical incidents — the 2021 UEFA final, a botnet-driven cache bypass, a misconfigured client update that hammered our edge — and any model that can’t flag at least 14 of them with >10 minutes of lead time doesn’t ship. That discipline forced us to tune for recall at the high quantiles, not just for low MAPE on quiet Tuesdays.
import pandas as pd
import xgboost as xgb
from prophet import Prophet
# 1. Prophet for baseline seasonality (24-72h horizon)
prophet_df = features[['t', 'rps']].rename(columns={'t': 'ds', 'rps': 'y'})
m = Prophet(
yearly_seasonality=False,
weekly_seasonality=True,
daily_seasonality=True,
seasonality_mode='multiplicative',
interval_width=0.9
)
m.add_regressor('is_live_event')
m.fit(prophet_df)
forecast = m.predict(features[['ds', 'is_live_event']].rename(columns={'t': 'ds'}))
features['prophet_trend'] = forecast['trend'].values
features['residual'] = features['rps'] - forecast['yhat'].values
# 2. XGBoost on residuals + real-time signals for 5-60m correction
X = features[['prophet_trend', 'rps_lag_1h', 'rps_ma_1h', 'rps_volatility_3h', 'miss_rate', 'geo_entropy']]
y = features['residual']
model = xgb.XGBRegressor(
n_estimators=800,
max_depth=7,
learning_rate=0.03,
subsample=0.8,
quantile_alpha=0.9, # train for P90 to favor over-provisioning
tree_method='hist'
)
model.fit(X, y)
# P90 forecast = prophet baseline + predicted residual
p90_forecast = forecast['yhat'].values + model.predict(X)
Turning Forecasts Into Capacity Decisions Before The Spike Lands
A forecast by itself doesn’t prevent an outage. What matters is the automation you attach to it. We map prediction intervals directly to actions with a tiered playbook. If the P50 forecast says we’ll exceed 75% of committed CDN capacity in the next 30 minutes, we pre-warm additional cache nodes and raise origin connection pools. If the P90 forecast exceeds 85% within 15 minutes, we trigger PoP-level load shedding policies, expand L2 shielding, and freeze non-critical purges and log exports. That second tier is intentionally conservative — I’d rather briefly over-provision by 15% than explain a 20-minute brownout because we tried to be precise.
We also stopped treating forecasts as point estimates. Every prediction we serve includes P50, P80, and P95 bands, and our capacity planner uses the width of those bands as a confidence score. A narrow band with high predicted RPS means we scale hard and fast. A wide band means we scale earlier but smaller and keep human eyes on it. This approach saved us last March when a forecast correctly flagged a 2.5x spike for a game patch, but the confidence was low; we staged capacity instead of committing it all, which was the right call when the actual peak landed at 1.6x.
The final piece is closing the loop. Every forecast is logged with its inputs, and we continuously compare predicted vs. observed RPS to compute drift and MAPE per PoP. If drift exceeds 8% for two consecutive hours, the pipeline auto-retrains. That feedback is what makes the system trustworthy for on-call engineers — they’ve seen it correct itself after a routing change, so they don’t second-guess it at 2 a.m.
| Forecast Approach | 7-Day MAPE (Peak Hours) | Usable Horizon | Lead Time Before Peak | p95 Inference Latency |
|---|---|---|---|---|
| Threshold + 5-min Moving Avg | 22.4% | ~5 minutes | 0 - 2 min (reactive) | ~8 ms |
| ARIMA(2,1,2) per PoP | 14.1% | 30 - 60 minutes | 4 - 6 min | ~45 ms |
| Prophet + XGBoost Residual (P90) | 6.8% | 15 min - 72 hours | 11 - 18 min | ~120 ms |
| LSTM Sequence (2-layer, 128 units) | 7.2% | 15 min - 6 hours | 9 - 14 min | ~310 ms |
| Prophet + XGBoost (P90) with Geo/Miss Features | 5.1% | 5 min - 72 hours | 13 - 22 min | ~135 ms |
Putting Forecasts to Work: Wiring Predictions Into Your Edge Stack
Look, a forecast sitting in a Grafana dashboard doesn't prevent a single 503. You've got to close the loop and make the prediction actually *do* something before the traffic lands. In our production setup, we run a lightweight forecasting service that consumes aggregated request rates every 30 seconds from Kafka, pushes a 15-minute ahead forecast, and directly drives three actuators: autoscaling, cache warming, and origin shield tuning. If your forecast latency is more than 45-60 seconds end-to-end, you're already too late for a flash crowd.
We learned the hard way that you can't just scale on predicted RPS alone. You need to join that forecast with your content mix. A predicted 40% spike in 4K video segments at 8 Mbps hits your egress and disk I/O completely differently than a 40% spike in API calls. That's where enriching your demand signal with behavioral data pays off. By feeding in Clickstream Analysis at CDN Edge: Understanding User Navigation Patterns we can distinguish a viral product page view surge (cacheable) from a checkout API storm (not cacheable). And if you're tracking User Journey Analytics from CDN Logs: Mapping the Path to Conversion, you can weight your forecast by funnel position — users in the last mile of a journey are far less tolerant of a cold cache miss.
The sweet spot we've found is a hybrid model: Prophet for daily/weekly seasonality plus an XGBoost regressor for short-term residuals driven by real-time features like active sessions, 5xx rate, and edge CPU. It keeps our 10-minute ahead MAPE under 8% on normal days and under 14% during volatile events, compared to 22%+ with ARIMA alone. Don't overcomplicate it at first — start with one region and one traffic class.
# forecast_actuator.py - simplified production actuator
import pandas as pd
from prophet import Prophet
def generate_forecast(df: pd.DataFrame, horizon_mins=15):
# df expects columns: ds (timestamp), y (req/s), sessions, cache_hit_ratio
model = Prophet(
yearly_seasonality=False,
weekly_seasonality=True,
daily_seasonality=True,
interval_width=0.85
)
model.add_regressor('sessions')
model.fit(df)
future = model.make_future_dataframe(periods=horizon_mins, freq='min')
# Forward-fill regressors for future periods from real-time feature store
future['sessions'] = df['sessions'].iloc[-1]
forecast = model.predict(future)
peak_rps = forecast['yhat'].tail(horizon_mins).max()
p85_rps = forecast['yhat_upper'].tail(horizon_mins).max()
# Actuation logic: scale if p85 exceeds 75% of current capacity
if p85_rps > (CURRENT_CAPACITY_RPS * 0.75):
scale_edge_tier(target_rps=p85_rps * 1.3) # 30% headroom
warm_cache(predicted_hot_keys=forecast)
return peak_rps, p85_rps
Advanced Patterns and Why Your Forecast Still Misses
Even with a solid pipeline, you'll get burned by two failure modes: cold starts and feedback loops. Cold starts happen when there's no history — a new customer onboarding, a brand new PoP, or that one-off Super Bowl ad. Your model has nothing to learn from, so it confidently predicts nothing. For that, we fall back to a cohort-based baseline. If a new streaming customer looks like our existing sports OTT cohort, we borrow their seasonality profile for the first 14 days and cap autoscaling at 2x per 5-minute window to avoid runaway scale-downs. It's conservative, but it stops you from under-provisioning by 300% on day one.
The nastier problem is the feedback loop. Your forecast triggers a pre-warm, which improves cache hit ratio from 92% to 98%, which *lowers* origin load, which your model then sees as "demand dropped" and scales back down. Then the cache churns and you're back to square one. You've got to forecast *demand* (client intent), not just *load* (origin hits). We separate those metrics explicitly — forecast ingress RPS at the edge, not egress to origin. And we log every automated scaling decision as a feature so the model knows "we intervened here" — otherwise you're training on your own mitigation.
Finally, don't forget attribution accuracy when you're correlating traffic spikes to business events. If you're scaling based on conversion-driven campaigns, noisy tracking will poison your regressors. Moving to Conversion Attribution from CDN Edge: First-Party Tracking Without Client-Side Scripts cleaned up our feature store dramatically — we cut false positives from ad-blocker-skewed client-side pixels by about 60% and our XGBoost feature importance for `campaign_clicks` actually became predictive instead of random noise. If your forecast's confidence interval is wider than ±25% for a 5-minute horizon, don't auto-act. Alert a human and switch to rate limiting instead.
Frequently Asked Questions
How far ahead should we forecast for effective capacity action?
For most CDN workloads, 10-15 minutes is the goldilocks zone. Less than 5 minutes doesn't give your autoscaler or cache warmer time to propagate — container spin-up alone takes 90-120 seconds plus another 60s for consistent hashing to stabilize. Beyond 30 minutes, accuracy degrades fast; our MAPE doubles from ~9% at 15 mins to ~19% at 60 mins. Keep a 60-minute forecast for staffing and origin shield pre-warming, but only auto-act on the 10-15 minute window.
What data granularity do we actually need?
Don't feed your model 1-second data, you'll just model noise. We aggregate to 30-second or 1-minute buckets per PoP and per content type (HLS, DASH, API, static). That gives us 1,440 data points per day per series, enough to catch a 2-3 minute vertical ramp without drowning in cardinality. Store raw logs at 1-second resolution for forensics, but train and infer on the 1-minute rollup. And always keep at least 6 weeks of history to capture weekly seasonality.
Should we forecast bytes, requests, or concurrent connections?
Forecast all three, but trigger on different thresholds. Requests per second drives CPU and connection table exhaustion — that's your first bottleneck and usually hits at 70-80% CPU. Bytes per second (Gbps) drives NIC and transit costs — critical for video. Active concurrent connections predicts ephemeral port and file descriptor pressure, which kills you during slowloris-like API spikes. Our actuator evaluates `max(predicted_rps/headroom, predicted_gbps/headroom, predicted_conns/headroom)` and scales on whichever hits 75% first.
How do we handle a forecast that was just plain wrong?
You need a circuit breaker, not just a better model. If actual RPS deviates from p85 by more than 35% for 2 consecutive minutes, we immediately freeze autoscaling, revert to threshold-based scaling (scale if CPU >65% for 60s), and page the on-call. We also snapshot the feature vector for post-mortem — 80% of our big misses weren't model bugs, they were upstream data delays where Kafka lagged by 90 seconds. Monitor your feature freshness as strictly as your forecast error.