Automated Capacity Planning for Startups: Load Profiling, Bottleneck Detection, and Predictive Scaling
A practical guide to capacity planning for startup engineering teams without a dedicated platform team. Covers load profiling, bottleneck detection, automated right-sizing, predictive scaling, and lightweight dashboards using Prometheus, Grafana, Kubernetes HPA, and Cloudflare Workers analytics.
Most startups discover their capacity problems the wrong way: during a traffic spike, at 2 AM, with a degraded production system and an on-call engineer who has never seen a runbook. The incident gets resolved, a few alerts get added, and everyone moves on. The next spike catches them the same way.
The root issue is not alerting. It is that the team has no model of how their system behaves under load. Without that model, scaling decisions are reactive and usually wrong in one of two directions: over-provisioned and expensive, or under-provisioned and fragile.
This guide builds that model from scratch. No dedicated platform team required. The goal is a lightweight, automated capacity planning loop that a two-person engineering team can own.
Load Profiling: Understanding Your Traffic Shape
Before you can plan capacity, you need to characterize your load. Raw request counts are not enough. You need to understand the shape of your traffic across multiple dimensions.
Identifying Peak Patterns
The first step is decomposing traffic into its temporal components. Most production systems have three overlapping cycles:
- Diurnal: Traffic that follows the business day. B2B SaaS products often see a sharp ramp at 9 AM in the user’s timezone, a lunch dip, and a tail-off after 6 PM.
- Weekly: Consumer products often see weekend peaks. B2B products typically inverse this.
- Growth trend: The underlying baseline that climbs week over week.
The following Prometheus query separates the growth trend from the diurnal noise by computing a 7-day rolling average:
avg_over_time(rate(http_requests_total[5m])[7d:1h])
Subtract the rolling average from instantaneous rate to isolate the diurnal component:
rate(http_requests_total[5m])
- avg_over_time(rate(http_requests_total[5m])[7d:1h])
This gives you the excess load above baseline. If peak excess consistently reaches 3x baseline, your provisioning target is baseline plus headroom for 3x spikes, not just baseline plus a flat 20% buffer.
Building a Growth Curve
The growth trend is the most important input to capacity planning. Collect weekly p95 request rate and plot it. If it follows a power law (common at early growth stages), linear extrapolation will underestimate future load. If it is linear, you can project forward with reasonable confidence.
A simple TypeScript utility to fit and project the trend:
type DataPoint = { weekOffset: number; rps: number };
function fitLinearTrend(data: DataPoint[]): (week: number) => number {
const n = data.length;
const sumX = data.reduce((s, d) => s + d.weekOffset, 0);
const sumY = data.reduce((s, d) => s + d.rps, 0);
const sumXY = data.reduce((s, d) => s + d.weekOffset * d.rps, 0);
const sumX2 = data.reduce((s, d) => s + d.weekOffset ** 2, 0);
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX ** 2);
const intercept = (sumY - slope * sumX) / n;
return (week: number) => slope * week + intercept;
}
// Usage: project 8 weeks out
const project = fitLinearTrend(historicalData);
const projectedRps = project(currentWeek + 8);
const capacityNeeded = projectedRps * 1.5; // 50% headroom over projection
Run this weekly in a cron job and write the output to your capacity planning dashboard. When the projection starts diverging significantly from actual (say, actual is 20% above projection for two consecutive weeks), that is your signal to re-fit the model.
Bottleneck Detection: CPU vs Memory vs I/O vs Network
Once you understand traffic shape, the next question is: what breaks first? Different resource types saturate differently, and the fix for each is different.
The Four Saturation Axes
| Resource | Saturation Signal | Leading Indicator | Typical Fix |
|---|---|---|---|
| CPU | container_cpu_usage_seconds_total near limit | Throttling rate > 25% | Increase CPU request/limit or reduce computation |
| Memory | OOMKill events, RSS near limit | GC pressure, heap growth | Increase memory or fix leak |
| I/O (disk) | node_disk_io_time_seconds_total near 1.0 | Queue depth > 1 | SSD upgrade, RAID, or move workload |
| Network | TX/RX bandwidth near interface cap | Retransmit rate increasing | Move to higher-bandwidth instance |
The most common mistake is treating high CPU as the primary bottleneck when the real constraint is I/O wait. When a process is blocked on disk or network, it appears idle from a CPU perspective but the application is still slow. Use iowait metrics to distinguish CPU-bound from I/O-bound saturation.
Kubernetes CPU Throttling
CPU throttling is particularly treacherous in Kubernetes because it is invisible in most default dashboards. A container can have 0% CPU usage in terms of cpu_usage / cpu_limit and still be throttled if it tries to burst within a period.
This Prometheus query surfaces containers with significant throttling:
rate(container_cpu_cfs_throttled_seconds_total[5m])
/ rate(container_cpu_cfs_periods_total[5m])
> 0.25
If this ratio is consistently above 25% for a service, its CPU limit is too low for the workload’s burst pattern, even if average utilization looks fine. The fix is either raising the CPU limit or switching to a Burstable QoS class with no limit set (acceptable for non-latency-sensitive workloads).
Memory Leak Detection
Memory leaks in long-running services are subtle because they often appear as normal growth, not a spike. The key is to look at memory growth per request rather than absolute memory:
(
container_memory_working_set_bytes
/ rate(http_requests_total[1h])
)
by (pod, namespace)
If memory-per-request is trending upward across pod restarts, you have a leak. If it is stable but total memory is growing, you are simply seeing normal baseline growth from more traffic, and the fix is to increase the memory limit and add more replicas.
Automated Right-Sizing with Kubernetes HPA
Horizontal Pod Autoscaler is the primary tool for automated capacity management in Kubernetes, but its defaults are poorly suited to most production workloads. Here is a configuration that reflects real-world production requirements:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-server
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-server
minReplicas: 3
maxReplicas: 50
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 120
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "500"
Several decisions here are worth explaining.
Target CPU utilization of 60%, not 80%. The HPA acts on a lagging signal. By the time the metric is scraped, aggregated, and a scaling decision is made, 30-60 seconds have passed. At 80% utilization, you have almost no headroom for latency spikes while new pods start. At 60%, you preserve enough buffer for p99 requests to complete cleanly during scale-out.
Asymmetric scale behavior. Scale up aggressively (100% of pods per minute), scale down conservatively (25% of pods every 2 minutes with a 5-minute stabilization window). Traffic spikes are fast. Recovery from over-scaling is slow and painful. Erring toward more replicas costs money; erring toward fewer replicas causes incidents.
Dual metrics. CPU utilization alone misses workloads where requests queue in the application layer rather than saturating CPU (common with async I/O). Adding http_requests_per_second per pod as a second scaling target catches this pattern.
Predictive Scaling Using Historical Trends
Reactive scaling (HPA responding to current load) has an inherent lag. For predictable traffic patterns, you can front-run demand by pre-scaling before the load arrives.
Kubernetes KEDA for Cron-Based Scaling
KEDA (Kubernetes Event-Driven Autoscaler) supports cron-based scaled objects that override the replica count on a schedule:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: api-server-predictive
namespace: production
spec:
scaleTargetRef:
name: api-server
minReplicaCount: 3
maxReplicaCount: 50
triggers:
- type: cron
metadata:
timezone: America/New_York
start: 30 8 * * 1-5 # 8:30 AM weekdays
end: 00 19 * * 1-5 # 7:00 PM weekdays
desiredReplicas: "12"
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring.svc:9090
metricName: http_requests_per_second
threshold: "500"
query: >
sum(rate(http_requests_total{namespace="production"}[2m]))
The cron trigger pre-scales to 12 replicas 30 minutes before business hours. The Prometheus trigger then takes over to handle traffic above the pre-scaled baseline. This eliminates the cold-start latency penalty that reactive HPA would otherwise incur at the beginning of every business day.
Cloudflare Workers Analytics for Edge Load Profiling
If your architecture routes traffic through Cloudflare Workers, the Analytics Engine provides sub-minute resolution data that is significantly ahead of what reaches your origin. Use it to detect traffic shape changes before they hit your application layer:
interface WorkerAnalyticsRow {
timestamp: number;
requestCount: number;
errorRate: number;
p95LatencyMs: number;
colo: string;
}
async function fetchEdgeLoadProfile(
accountId: string,
apiToken: string,
hoursBack: number
): Promise<WorkerAnalyticsRow[]> {
const query = `
SELECT
toStartOfMinute(timestamp) AS timestamp,
sum(requests) AS requestCount,
sum(errors) / sum(requests) AS errorRate,
quantileExact(0.95)(responseBodySize) AS p95LatencyMs,
coloCode AS colo
FROM workers_analytics
WHERE timestamp > now() - INTERVAL '${hoursBack}' HOUR
GROUP BY timestamp, colo
ORDER BY timestamp DESC
`;
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query }),
}
);
const data = await response.json();
return data.result.rows;
}
The practical use here is anomaly detection. If edge request volume spikes 40% above the diurnal baseline at 10 AM on a Tuesday, you have 30-60 seconds to pre-scale origin before the requests propagate through your cache tier. Feed this signal into a Lambda or Cloud Run function that calls the Kubernetes API to temporarily increase deployment replicas.
Cost-Aware Capacity Decisions
More capacity costs more money. The question is not “how much capacity do I need to be safe” but “what is the cost of a given failure probability, and does that cost exceed the cost of the extra capacity?”
A simple model: if your p99 latency exceeds SLA during saturation events that occur twice per month, and each event costs an estimated $2K in support overhead and churn, then $4K/month in additional compute is break-even. Anything below that is worth provisioning.
This framing changes how you approach right-sizing. Instead of asking “how do I minimize cloud spend,” ask “what is the cost of being wrong in each direction?”
Spot Instance Strategy for Non-Critical Workloads
Batch processing, analytics pipelines, and non-latency-sensitive background jobs are good candidates for spot/preemptible instances at 60-80% cost reduction. The key constraint is that your job must be checkpointable: if the instance is reclaimed, the job resumes from the last checkpoint, not from scratch.
A TypeScript pattern for a checkpoint-aware batch processor:
interface CheckpointState {
processedCount: number;
lastProcessedId: string;
startedAt: number;
}
async function resumableProcessor(
checkpointStore: CheckpointStore,
source: AsyncIterable<Record>
): Promise<void> {
let state = await checkpointStore.load() ?? {
processedCount: 0,
lastProcessedId: "",
startedAt: Date.now(),
};
const CHECKPOINT_INTERVAL = 1000;
for await (const record of source) {
if (record.id <= state.lastProcessedId) {
continue; // Already processed, skip
}
await processRecord(record);
state.processedCount++;
state.lastProcessedId = record.id;
if (state.processedCount % CHECKPOINT_INTERVAL === 0) {
await checkpointStore.save(state);
}
}
await checkpointStore.save(state);
}
Run this on spot instances. If the instance is reclaimed, the next run picks up from the last checkpoint. The cost savings are real enough to justify the engineering investment.
Building a Lightweight Capacity Dashboard
You do not need a dedicated platform team to maintain a useful capacity planning dashboard. A Grafana dashboard with four panels covers most of what a startup engineering team needs to make informed capacity decisions.
Panel 1: Traffic trend with projection. Plot actual rate(http_requests_total[5m]) alongside the 8-week linear projection computed by the trend-fitting script described earlier. When actuals diverge from the projection by more than 20%, that panel turns yellow.
Panel 2: Saturation heatmap. For each of your top 10 services, show CPU throttle ratio, memory utilization, and I/O wait as a color grid. Green below 50%, yellow 50-75%, red above 75%. This gives a single-glance view of where headroom is tightest.
Panel 3: HPA scale events. A timeline of scale-up and scale-down events per service, overlaid with request rate. This surfaces whether your HPA configuration is tracking load correctly or lagging too far behind.
Panel 4: Cost-per-request trend. Monthly cloud bill divided by monthly request count, plotted over 90 days. If cost-per-request is growing while traffic grows, your scaling is not efficient. If it is flat or declining, you are right-sizing correctly.
A single Grafana dashboard with these four panels, reviewed in the weekly engineering sync, is sufficient for a team of five engineers to maintain situational awareness of capacity without a platform team.
Production Considerations
A few things that matter in practice and are often omitted from guides like this.
Warm-up time is real. JVM-based services, Python services with large model loads, and any service that builds in-memory caches at startup will have degraded performance for the first 30-60 seconds after a pod starts. Configure HPA minReplicas so you are never one pod restart away from a cold-start incident under load. Three is the minimum for any latency-sensitive service.
Metric staleness causes over-scaling. If your metrics scrape interval is 30 seconds and your HPA sync period is also 30 seconds, you can end up with HPA making decisions on 60-second-old data. Under rapidly changing load, this causes oscillation. Reduce scrape interval to 15 seconds for services with fast load variability.
Namespace resource quotas prevent runaway scaling. Set ResourceQuota on production namespaces to cap total CPU and memory. This prevents a misconfigured HPA from scaling a service to 500 replicas and consuming the entire cluster, which is an incident of a different kind.
apiVersion: v1
kind: ResourceQuota
metadata:
name: production-quota
namespace: production
spec:
hard:
requests.cpu: "200"
requests.memory: 400Gi
limits.cpu: "400"
limits.memory: 800Gi
count/pods: "500"
Load test before you need it. The only way to validate your capacity model is to run a synthetic load test that matches your peak traffic profile and verify that the system scales correctly, latency stays within SLA, and cost scales linearly with load. Do this before a major launch, not after. Tools like k6 make this straightforward to automate as a pre-launch gate.
The Feedback Loop
Capacity planning is not a one-time exercise. It is a loop: profile the load, detect bottlenecks, right-size the system, project future demand, validate the projection against actuals, and repeat.
The difference between a startup that handles 10x growth cleanly and one that has a series of production incidents during growth is not talent. It is whether this loop is running continuously or only gets attention after things break.
A two-hour investment per week in reviewing the capacity dashboard, updating the growth projection, and acting on saturation signals early is enough to stay ahead of the curve. The infrastructure described here makes that investment possible without a dedicated platform team.
More in DevOps
How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.
How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.
How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.
How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.