How Do I Detect an Undersized Runner?
A GitHub Actions runner is undersized when max sustained CPU or memory utilization holds at 80 percent or higher across a job's runs. Read both signals.
Last verified:
A runner is undersized when a job holds max sustained CPU at the ceiling for most of its duration, or when its memory line climbs to a plateau and flattens there right before a step slows down or a process is killed. Both shapes are visible per job in the Recommendations view, which applies a fixed 80 percent threshold to CPU, memory, filesystem, and disk I/O and marks the instance as under-provisioned.
Answer
Two signal shapes account for almost every genuine case, and they call for different changes.
| Signal shape | What the chart shows | What it means | What to change |
|---|---|---|---|
| CPU ceiling | Max sustained CPU pinned at or above 80 percent for most of the job, memory well below its own line | The job is compute bound and the cores are the constraint | Move up one label for more vCPU |
| Memory plateau | Memory climbs, flattens near the top, and stays flat while the running step slows or ends early | The job is at the memory cap, and a process is being paged or killed | Move up one label, since memory and vCPU scale together in this catalog |
The thresholds behind the alert labels are fixed and published, so the reading is the same for every job in the organization.
| Metric | Threshold | Alert label |
|---|---|---|
| Max sustained CPU | 80 percent or higher | High CPU Usage |
| Max memory utilization | 80 percent or higher | High Memory Usage |
| Max filesystem utilization | 80 percent or higher | High Filesystem Usage |
| Max disk I/O | 80 percent or higher of supported throughput | High Disk IO |
Three collection details change how you read those numbers, all from the observability documentation. Max sustained CPU is the maximum rolling average over the last 30 seconds rather than an instantaneous peak, which is why a single spiky step rarely trips it. Disk I/O and network are read the same way, as rolling averages over 30 seconds. And metrics and logs are collected only for jobs longer than about one minute, so a fleet of 40 second jobs shows gaps where the evidence would be.
. , and the same five metrics come off each one. The upgrade path differs by platform: Linux and Windows offer a doubling ladder, while the macOS catalog carries two shapes, 6 vCPU with 22 GB and 12 vCPU with 44 GB, so the macOS decision is a single step rather than a rung.
Detail
Reading each shape without guessing
A CPU ceiling that holds for most of the job is the clean case. Cores are saturated for the whole run, the duration scales with core count, and the next label up returns wall-clock time in proportion. A CPU line that touches 80 percent for 20 seconds of a 12 minute job is a different reading entirely, and moving up buys nothing.
The memory plateau is the shape worth learning, because it hides. Memory climbs during a build step, reaches a level close to the instance total, and then goes flat while throughput on the same step drops. That flat top is the cap, and what happens next shows in the logs view, which carries both runner system logs and GitHub Actions logs on one timeline. A step that stops with exit code 137 next to a kill message in syslog is a memory cap that was hit rather than a step that failed on its own.
Filesystem utilization at the threshold usually points somewhere else. The 150GB SSD on Linux runners filling up is normally artifacts left behind inside the job rather than a machine that is too small, and the fix is cleanup or a larger disk tier rather than more cores. High disk I/O with idle cores is the cold cache signature covered further down.
The endpoint that returns a suggested runner per job
It returns, for every GitHub job in the organization's last 7 days that should move to a different runner size, a right-sizing recommendation derived from that job's aggregate CPU and memory usage across all its runs, plus a sample of its most recent runs.
curl -X GET 'https://api.warpbuild.com/api/v1/org_metrics/job_runner_recommendations?search=integration&per_page=50' \
-H 'Accept: application/json' \
-H 'Authorization: Bearer wkey-xxxx'A single entry in the recommendations array looks like this.
{
"repository": "acme/platform",
"workflow_name": "ci.yml",
"job_name": "integration-tests",
"total_runs": 148,
"recommendation": {
"type": "upgrade",
"resources": ["cpu"],
"current_label": "warp-ubuntu-latest-x64-4x",
"current_vcpu": 4,
"current_memory_gb": 16,
"recommended_label": "warp-ubuntu-latest-x64-8x",
"recommended_vcpu": 8,
"recommended_memory_gb": 32,
"already_at_max_size": false
},
"runs": [
{
"runner_display_name": "warp-ubuntu-latest-x64-4x",
"max_sustained_cpu": 93.4,
"max_memory_utilization": 61.2,
"max_filesystem_utilization": 38.9,
"job_link": "https://github.com/acme/platform/actions/runs/..."
}
]
}Four fields carry the decision. type is upgrade or downgrade. resources is the subset of cpu, memory, and disk that is hot on an upgrade or underused on a downgrade, which tells you which of the two shapes above triggered it. recommended_label is the suggested runner. And already_at_max_size is set when a job is on the largest runner and still needs more, which is a sharding problem rather than a sizing one.
The query parameters are search across repository, workflow, and job name, repo_workflows for specific pairs, account_id, page, and per_page with a default of 50 and a maximum of 200. The operation is marked alpha in the API reference. Authentication is a bearer API key created on the API keys page with the ci scope, the same key type the automation documentation uses for the runner and runner image endpoints.
A worked before and after
Take the job in the response above. It runs 600 times a month on warp-ubuntu-latest-x64-4x at a p90 duration of 12.0 minutes, with max sustained CPU at 93.4 percent and memory at 61.2 percent. CPU is the only hot resource, which matches the resources array.
| Reading | Before | After |
|---|---|---|
| Label | warp-ubuntu-latest-x64-4x | warp-ubuntu-latest-x64-8x |
| Shape | 4 vCPU, 16 GB | 8 vCPU, 32 GB |
| Rate | $0.008/min | $0.016/min |
| p90 duration | 12.0 min | 7.0 min |
| Billed minutes, 600 runs | 7,200 | 4,200 |
| Monthly cost | $57.60 | $67.20 |
The rate doubles at that step, so the break-even duration on 8 vCPU is 6.0 minutes. Landing at 7.0 costs $9.60 more a month and returns 5 minutes on every run, which is 3,000 minutes a month of waiting removed from pull requests. That is a purchase of wall-clock time, and it should be a deliberate one.
The rate difference against the nearest GitHub-hosted shape sits at the same step. warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13 at docs.github.com. The same 4,200 minutes bill $92.40 on that shape.
Re-read the same job a week after the change. The recommendation window is the last 7 days, so a correct upgrade drops the job out of the response entirely.
What looks undersized and is not
Four cases produce the same complaint and need a different fix.
A cold cache shows high disk I/O with cores mostly idle and a long p90 tail. The machine is downloading dependencies, and a larger machine downloads them at the same rate.
Queue time lives outside the instance. When jobs wait rather than run, the number to move is concurrency and label routing, and no size changes it.
A single-threaded step ignores vCPU. A job dominated by one long compile, a linker, an artifact upload, or a rate-limited API call runs the same wall-clock minutes on 32 vCPU as on 4 while the invoice doubles at every step.
A job under about one minute has no telemetry at all, so absence of a signal there is a collection gap rather than a healthy runner.
Detection is the cheapest lever of the set. The observability solution page covers the wider metric set per job, and the right-sizing guide works through the ladder once detection is done.
Related Questions
What CPU number means a GitHub Actions runner is too small?
Max sustained CPU of 80 percent or higher, where max sustained CPU is the maximum rolling average over the last 30 seconds. The Recommendations view labels an instance at or above that line as High CPU Usage. One hot run proves little, so read the value across a week of runs of the same job, and see runner utilization for how each metric is derived.
How do I tell an undersized runner from a cold cache?
Read disk I/O against CPU. A cold cache shows high disk I/O with cores mostly idle, because the job is pulling dependencies rather than computing. An undersized runner shows CPU at the ceiling while disk I/O stays moderate. A bigger machine downloads the same dependencies at the same rate, so fix the cache first and re-read the chart on the observability solution page.
Is there an API that returns a recommended runner size per job?
Yes. GET /org_metrics/job_runner_recommendations returns, for every job in the organization's last 7 days that should move size, the current label, the recommended label, whether the move is an upgrade or a downgrade, which resources are hot, and a sample of recent runs with their utilization. The operation is marked alpha, and it authenticates with a bearer API key created as described in the automation documentation.
What does moving up one Linux size cost?
Each Linux x64 step doubles the rate, so warp-ubuntu-latest-x64-4x at $0.008 per minute becomes warp-ubuntu-latest-x64-8x at $0.016 per minute on the pricing page. The bill stays flat only when the job finishes in under half the time. Anything above that is buying wall-clock time back, and how to choose a runner size works through the ladder step by step.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.