CPU throttling is the forced pausing of a process or container by the Linux scheduler once it has used up its CPU quota for the current scheduling period. In Kubernetes it results from CPU limits enforced through CFS bandwidth control and often raises tail latency even when average CPU utilization looks low.
What is CPU throttling?
CPU throttling, in the context of containers, means that the Linux kernel stops scheduling a group of processes because they have consumed their allowed CPU time for the current period. The threads are ready to run, but they have to wait until the next period starts. The term is also used for hardware that lowers its clock speed because of heat or power limits; this article covers the container and cgroup meaning, which is the one most relevant for cloud-native services.
Throttling is a classic source of confusing latency: dashboards show a container at moderate average CPU usage, yet requests stall for tens of milliseconds at a time.
How CFS quotas work
Kubernetes CPU limits are enforced through the Completely Fair Scheduler's bandwidth control. Each cgroup gets a quota of CPU time per period, written to cpu.max in cgroup v2 (or cpu.cfs_quota_us and cpu.cfs_period_us in v1). The default period is 100 ms. A limit of 500m (half a CPU) becomes 50 ms of CPU time per 100 ms period.
The quota is shared by all threads in the container. A service with eight busy threads can use the full 50 ms in about 6 ms of wall-clock time and is then paused for the remaining 94 ms of the period. Average utilization over a minute may still look like 30 %, because the throttling happens in short bursts that averages hide.
CPU requests work differently: they set a relative weight used when CPU is contended and influence scheduling, but they do not cap usage. Only limits cause throttling.
How to measure CPU throttling
The kernel counts throttling in cpu.stat: nr_periods, nr_throttled and throttled_usec. cAdvisor exposes these as container_cpu_cfs_periods_total, container_cpu_cfs_throttled_periods_total and container_cpu_cfs_throttled_seconds_total. An illustrative PromQL query for the share of throttled periods:
sum by (pod) (rate(container_cpu_cfs_throttled_periods_total{container="api"}[5m]))
/
sum by (pod) (rate(container_cpu_cfs_periods_total{container="api"}[5m]))
A value of 0.25 would mean that a quarter of all scheduling periods ended with the container paused. Plot it next to request latency percentiles, because throttling shows up in p99 long before it moves the median.
Why throttling hurts tail latency
A throttled container does not slow down evenly. It runs at full speed until the quota is gone and then stops completely until the next period begins. A request that arrives just before that moment, or that needs slightly more CPU than usual, has to wait for up to the rest of the period, often tens of milliseconds, before it can continue. If a request spans several periods, the waits add up. Downstream services see this as sporadic slow calls, retries and timeouts, which can amplify the load and cause more throttling.
Because the effect is concentrated in short bursts, the usual one-minute CPU graph does not show it. Garbage collection makes it worse: a collector that starts many parallel threads at once can use most of the quota in one go and pause the application threads that follow. This is why throttling should be read together with latency distributions rather than with average CPU alone.
Example: throttling despite low utilization
An illustrative case: a Go API with a CPU limit of 1 core runs on a node with 16 cores. Before Go 1.25, the runtime set GOMAXPROCS to the number of host cores rather than the container limit, so 16 threads compete for 100 ms of CPU per period and exhaust it within a few milliseconds during bursts. The service shows about 40 % average usage, yet p99 latency is several times the median. Setting GOMAXPROCS to match the limit, or using a Go version that reads the cgroup limit, reduces throttled periods and smooths the tail. A similar effect occurs in older JVMs that sized thread pools and garbage collection threads by host cores.
Best practices
- Set CPU requests based on observed usage so the scheduler places pods sensibly.
- Decide deliberately whether latency-sensitive services need CPU limits at all; many teams rely on requests and node-level isolation instead, while others keep limits for fairness and cost control.
- If you keep limits, size them for bursts, not averages, and watch the throttled-period ratio.
- Align runtime parallelism (thread pools,
GOMAXPROCS, GC threads) with the CPU limit. - Distinguish throttling from contention: a noisy neighbor slows you down through shared CPU, while throttling comes from your own quota.
How Obseria helps
Obseria's Kubernetes monitoring shows CPU usage against requests and limits together with throttled periods per container, and lets you correlate them with trace latency of the same pods. Continuous profiling shows which code consumes the CPU quota. Throttling and memory pressure often appear together in under-sized workloads, see OOM kill.