DevOps ·

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.

Automated Capacity Planning for Startups: Load Profiling, Bottleneck Detection, and Predictive Scaling

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

ResourceSaturation SignalLeading IndicatorTypical Fix
CPUcontainer_cpu_usage_seconds_total near limitThrottling rate > 25%Increase CPU request/limit or reduce computation
MemoryOOMKill events, RSS near limitGC pressure, heap growthIncrease memory or fix leak
I/O (disk)node_disk_io_time_seconds_total near 1.0Queue depth > 1SSD upgrade, RAID, or move workload
NetworkTX/RX bandwidth near interface capRetransmit rate increasingMove 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.


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
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
DevOps ·

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
DevOps ·

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
DevOps ·

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.