Idle Time Inside GitHub Actions Jobs
Idle minutes inside a GitHub Actions job bill like busy ones. Spot them from low CPU against a long duration, name the four sources, and price the fix.
Last verified:
Idle time inside a GitHub Actions job is wall clock the job spends holding a machine that is doing no work, and it bills at the same per-minute rate as the busy minutes around it. You find it by pairing a long duration against a low CPU number for the same job, then reading the runner utilization chart against the step boundaries to name what the job was waiting on.
This guide covers the four sources that produce almost all of it, the metric signature each one leaves, the workflow changes that remove them, and a worked monthly cost for the idle minutes at a stated job volume and runner size.
Diagnosis
Three quantities compete for the same complaint that a job is slow. Queue time is the wait before the job starts and is reported separately per runner label in Queue Timings. Busy execution is the machine working. Idle execution is the machine held and not working. Only the last two land in the job duration and on the invoice, and the split between them is what this page is about; the guide to finding the slow step covers the queue versus execution split in full.
The starting signal is one row in the Jobs report, which aggregates per unique repository, workflow, and job name with run count, duration p75 and p90, queue time p75 and p90, and CPU and memory p75 and p90, per the Reports documentation. Sort by duration p90 descending, then read the CPU column of the top rows.
| Duration p90 | CPU p90 | Reading |
|---|---|---|
| Minutes | Under 10 percent | Blocked. The job is waiting on something outside itself |
| Minutes | Near 100 divided by the vCPU count | One thread is working and the rest of the machine is parked |
| Minutes | 30 to 70 percent | Partly parallel work with idle stretches between the parallel parts |
| Minutes | 80 percent or higher | Core bound. Size is the lever and idle is not the story |
Two limits on that column. CPU and memory come from the telemetry agent, so jobs without observability enabled show a dash instead of a number, and metrics and logs are collected only for jobs longer than about one minute, per the Observability documentation. Collection can also be paused, which stops metrics along with system logs and GitHub Actions logs, so an empty chart is worth checking against the pause state before you read it as an idle machine.
The bigger trap is the direction of the inference. The Jobs report CPU column is a percentile over runs of each run's peak utilization, so one busy step sets the peak for the entire run. Low CPU p90 with a long duration is conclusive enough to open the job. High CPU p90 clears nothing, and a job that peaks at 92 percent can still hold eight idle minutes on either side of that peak. To see the shape over time, open the Usage view for a single runner instance, where the utilization chart and the GitHub Actions logs sit in the same view so a flat region can be matched to the step that was running when it started. The Recommendations view also filters and highlights instances that sit at low resource utilization, which is the fleet-wide version of the same read.
Four sources cover almost every flat region you will find, and each leaves a different signature across the CPU and network traces.
| Source | CPU trace | Network trace | Usual step |
|---|---|---|---|
| Waiting on a service | Flat near zero for one contiguous block | Flat, with a short burst at the end | A wait or health check before an integration suite |
| Fixed sleeps | Flat near zero in round-numbered blocks | Flat | Retry helpers and shell scripts with sleep |
| Polling loops | Near zero with a small sawtooth | Regular spikes at the poll interval | aws ... wait, kubectl rollout status, custom until loops |
| Single-threaded step on a large machine | Stable at 100 divided by the vCPU count | Flat | Migrations, single-process linters, sequential packaging |
The fourth one reads as idle in the cost sense even though the machine is doing work, because you are paying for every core while one of them runs. The ceiling is arithmetic, and the rates come from the pricing page:
| Label | vCPU | One busy thread shows | USD per minute |
|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 | 50 percent | $0.004 |
warp-ubuntu-latest-x64-4x | 4 | 25 percent | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 12.5 percent | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 6.3 percent | $0.032 |
warp-ubuntu-latest-x64-32x | 32 | 3.1 percent | $0.064 |
A step holding a steady 6 percent on a 16 vCPU runner is the signature of one saturated core, and a step holding a steady 6 percent on a 2 vCPU runner is a genuinely quiet machine. Read the number against the label rather than on its own.
Fix
Step 1: confirm the minutes are inside the job. Compare queue time p90 against duration p90 for the same window. If queue wait moved, the work is capacity rather than idle.
Step 2: rank by duration p90 and read CPU ascending. The long jobs with the lowest CPU are the candidates. Export the table to CSV from the report if you want to sort several months of rows in a spreadsheet.
Step 3: name the step. Open the Usage view for one instance of a run near that p90, find the flat region, and read the GitHub Actions logs in the same view to see which step held it.
Step 4: classify against the four signatures above, then apply the matching lever.
- Waiting on a service. Move the wait into the platform. Health check options on a service container make the job wait for the container to report healthy before the first step runs, per the workflow syntax reference, checked on 2026-08-13. For anything the runner starts itself, replace the fixed wait with a readiness probe that returns the moment the port answers.
- Fixed sleeps. Delete them. A
sleep 30inserted to make a flaky step pass bills 30 seconds on every run, including the runs that needed none of it. Where a retry is genuinely required, back off from a short interval and cap the total wait. - Polling loops. The poll itself is usually cheap and the wait is not. Move the wait off the expensive machine into its own job on a small label, so the deployment gate stops renting 16 vCPU to run
curlevery ten seconds. - Single-threaded step on a large machine. Two levers. Shard the work across parallel jobs so the cores are used by concurrency, following right sizing GitHub Actions runners for the size decision. Or split the step into its own job on a smaller label, which is worth measuring first because it trades machine cost against an extra boot and a state handoff.
Step 5: re-measure against run count. The Jobs report gives the run count next to the percentiles, so a job with 150 runs a week produces a trustworthy new p90 within days while a job with 12 runs a week needs a month.
Every runner WarpBuild operates carries the same agent, so the telemetry has the same shape on all four platforms. A flat CPU trace with high disk I/O is a different diagnosis; see why a job shows high IO wait for that one.
Configuration
Start by finding the sleeps, which are the cheapest source to remove and the easiest to miss in review:
grep -rnE '(^|[^[:alnum:]_])sleep [0-9]+' .github/workflows scriptsReplace the ones guarding a service with a probe that exits as soon as the port answers:
#!/usr/bin/env bash
# scripts/wait-for-port.sh HOST PORT [TIMEOUT_SECONDS]
set -euo pipefail
host="$1"
port="$2"
deadline=$(( SECONDS + ${3:-120} ))
until (exec 3<>"/dev/tcp/${host}/${port}") 2>/dev/null; do
if (( SECONDS >= deadline )); then
echo "timed out waiting for ${host}:${port}" >&2
exit 1
fi
sleep 0.2
done
echo "${host}:${port} answered after ${SECONDS}s"The workflow below carries all three changes: health checked service containers so the job starts with its dependencies up, the probe in place of a fixed wait, and the deployment poll moved off the large runner into its own job:
name: integration
on:
pull_request:
jobs:
integration:
runs-on: warp-ubuntu-latest-x64-16x
timeout-minutes: 30
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 3s
--health-retries 20
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 20
steps:
- uses: actions/checkout@v5
- name: Start the application
run: ./scripts/start-app.sh &
- name: Wait for the application port
run: ./scripts/wait-for-port.sh 127.0.0.1 8080 120
- name: Run the suite
run: pytest -n 16 tests/
wait-for-rollout:
needs: integration
runs-on: warp-ubuntu-latest-x64-2x
timeout-minutes: 20
steps:
- name: Wait for the service to stabilize
run: aws ecs wait services-stable --cluster prod --services apiThe poll job keeps the same gate and pays $0.004 per minute instead of $0.032 while it waits.
Cost or Time Model
Assumptions. The integration job runs 600 times a month on warp-ubuntu-latest-x64-16x at $0.032 per minute from the pricing page, billed per minute. Duration p50 is 12.0 minutes. The segment timings below come from the Usage view for one run near that percentile.
| Segment | Minutes | CPU during the segment | Source |
|---|---|---|---|
| Waiting for Postgres and Redis | 2.4 | 1 percent | Waiting on a service |
| Fixed sleeps in retry helpers | 1.1 | 1 percent | Fixed sleeps |
aws ecs wait services-stable | 2.0 | 3 percent | Polling loop |
| Schema migration | 3.0 | 6 percent | Single thread on 16 vCPU |
| Test suite | 3.5 | 88 percent | Busy |
Baseline. 600 runs at 12.0 minutes is 7,200 minutes at $0.032, which is $230.40 a month. The three blocked segments total 5.5 minutes per run, so 3,300 of those minutes and $105.60 of that bill are a 16 vCPU machine holding still, which is 46 percent of the invoice for this job.
After the three changes. The readiness probe takes the service wait from 2.4 minutes to 0.4. The fixed sleeps go to zero. The 2.0 minute deployment poll leaves the large runner for its own job. The main job is now 6.9 minutes: 600 runs at 6.9 minutes is 4,140 minutes at $0.032, which is $132.48. The poll job bills 2.0 minutes plus about 0.3 minutes of boot at $0.004, which is 600 runs at 2.3 minutes for $5.52. The pair costs $138.00 a month against $230.40, a saving of $92.40.
Wall clock moves less than the bill does, because the poll still runs after the tests. End to end the pipeline drops 2.8 minutes per run, which is 1,680 minutes a month returned to the people waiting on the pull request, while billed minutes on the large runner drop by 5.1 per run.
The migration is the open item. 3.0 minutes at 600 runs is 1,800 minutes and $57.60 a month spent renting 16 cores for one. The same 3.0 minutes plus a 0.3 minute boot on warp-ubuntu-latest-x64-2x bills $7.92, so the move is worth $49.68 a month if the step runs at the same speed on the smaller label. Measure that on your own workload before committing, because a migration that slows down under a smaller cache or a different disk gives the minutes straight back.
For a list price baseline: warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price, per the GitHub Actions minute multipliers reference, GitHub list price checked on 2026-08-13. The same 4,140 minutes of the fixed job bill $173.88 at that rate against $132.48 here. Idle minutes bill at the full rate on any runner, so the lower list price reduces what idle costs without removing any of it, and the segment table is where the removal happens.
For the rest of the levers once the idle minutes are gone, see reducing GitHub Actions costs.
FAQ
How do I tell idle time from queue time in a GitHub Actions job?
Queue time is the wait before the job starts and it sits outside the job in the Queue Timings report. Idle time is inside the job, so it lands in the duration numbers and on the invoice. If duration p90 grew while queue time p90 stayed flat, the extra minutes are inside the job and the runner utilization chart will show where.
What CPU number means a GitHub Actions job is idle?
A job whose CPU p90 sits in single digits while its duration p90 runs into minutes is blocked rather than working. A high CPU p90 does not clear a job, because that column is a percentile of each run's peak and one busy step sets the peak for the whole run, so open the per-instance chart before deciding.
Do idle minutes cost money?
Yes. Billing is per minute of runner time, so a step that sleeps bills the same as a step that compiles. On warp-ubuntu-latest-x64-16x at $0.032 per minute, 5.5 idle minutes across 600 runs a month is 3,300 minutes and $105.60.
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.