How to find your slowest CI jobs with CI Insights
Enable CI Insights on the repository, open the Jobs page, and sort by duration over a 30-day window. Then multiply duration by how often each job runs. The job at the top of that second ranking is your real bottleneck, and it is usually not the one people complain about.
In one paragraph
CI Insights reads job data straight from GitHub Actions, Jenkins, or Buildkite, so the Jobs page fills in without you touching a workflow file. Sort by p95 duration to find the runs that hurt, then rank by duration times executions to find where the hours actually go. Check the Runners page to see how much of that time was spent waiting for a machine rather than running your code. Then cut the jobs that never needed to run in the first place.
Why does the pipeline feel slow when every job looks fine?
A pipeline finishes when its longest chain finishes, so the average job duration tells you nothing useful. Nine jobs at four minutes and one at forty gives you a forty-minute pipeline and a very reasonable-looking average of about eight. Ask most teams which job is the slow one and you will get an answer, confidently, and it will be the job somebody watched fail at 6pm last Thursday.
There are two other numbers folded into the one your CI shows you. The first is wait: the minutes a job spent queued for a runner before a single line of your code executed. The second is frequency. A nine-minute job triggered on every pull request costs your organization more hours per week than a nightly that takes an hour, and unlike the nightly it sits directly between an engineer and their merge.
Ranking jobs correctly takes about ten minutes once the data is there. For the concepts underneath, the continuous integration guide and the CI/CD pipeline guide cover how the stages fit together.
How do you get the data without touching your pipeline?
Job-level data comes from the CI provider itself, so there is nothing to instrument. Enable CI Insights on your repositories from the GitHub integration page in the dashboard, then re-run a workflow or push a commit. Durations, execution counts, failure rates, and cost show up on the Jobs page on the next run. Jenkins and Buildkite have their own setup guides and land in the same view.
Two controls do most of the work on that page. The date range selector sets the window your percentiles are computed over, and the grouping options let you split the same job by branch, runner group, or label. Grouping is how you catch the job that is fine on main and awful on pull requests, or fine on one runner label and awful on another.
Each job also carries a health status of healthy, flaky, or broken, based on its recent run history. A flaky job is one that produced two different conclusions on the same commit, which is a fact about your CI rather than an opinion about it. If flakiness is what you are chasing rather than duration, stopping flaky tests from blocking your merge queue picks up from there.
Which job is actually your bottleneck?
Sort by duration, note the p95 rather than the average, then multiply by executions. Say a 30-day window on one repository comes back looking like this:
| Job | p50 | p95 | Executions | Total time | How to read it |
|---|---|---|---|---|---|
integration-tests | 18m | 41m | 60 / day | 18h / day | Your bottleneck. Long, frequent, and a p95 more than double the median, so it is unstable on top of slow. |
build-docker | 9m | 11m | 60 / day | 9h / day | Predictable, but it runs on every pull request including the ones that only touch docs. The easiest hours to win back. |
unit-tests | 4m | 5m | 60 / day | 4h / day | Healthy. Short, tight spread, and already on the critical path of nothing. Leave it alone. |
e2e-nightly | 52m | 58m | 2 / day | 1.7h / day | The number everyone quotes in meetings, and the last one worth speeding up. |
The numbers are made up, the shape is not: the job everyone names ends up fourth on the list. Ranked by single-run duration, e2e-nightly wins by a mile. Ranked by total time, it costs a tenth of what integration-tests does, and speeding it up would change nobody's afternoon.
The gap between p50 and p95 is the second signal to read. A tight spread means a job that behaves, and shaving it takes real engineering. A p95 at double the median means something intermittent is going on, usually a shared service that times out or a cache that misses. That one is often cheap to fix once you can see it, which is why it is worth opening the executions view and clicking into the slow runs directly. Each execution has a detail panel with the full job timeline and the test results that came with it, so you can trace a regression back to the commit that introduced it.
Is the job slow, or is the machine?
A job that spends six minutes queued and four minutes running reports ten minutes, and every hour you spend adding caches to it will buy you nothing. The Runners page splits those apart. Configure which runner groups to track first, optionally filtered by label, then expand any runner row to get duration percentiles at median, p95, and p99, queue time broken out separately, total runs, failure rate, and cost.
Each runner also gets a health status of healthy, unstable, or degraded, scored against its own group, so a node that is limping shows up next to peers doing the same work rather than in the abstract. High queue times across a whole group point at fleet capacity. One runner underperforming its group points at that box. The runners documentation covers the fleet configuration, and we have no reason to push you toward any particular provider, since we do not sell runners.
Do this before you touch any test code. Roughly half the "slow CI" complaints we see resolve into "the fleet is too small at 10am", and that is a purchasing decision rather than an engineering project.
How do you stop the job from running at all?
The cheapest minute is the one you never spend. Look back at build-docker: nine minutes, sixty times a day, on every pull request whether or not the change could possibly affect the image. Making it faster is a week of work. Not running it on documentation changes is an afternoon.
Declare the areas of your repository as scopes in .mergify.yml:
scopes:
source:
files:
web:
include:
- apps/web/**/*
api:
include:
- services/api/**/*
docs:
include:
- docs/**/*Then have one small job resolve the scopes for the pull request and gate the expensive jobs on the ones they cover:
jobs:
detect-scopes:
runs-on: ubuntu-24.04
outputs:
api: ${{ fromJSON(steps.scopes.outputs.scopes).api }}
steps:
- uses: actions/checkout@v5
- id: scopes
uses: Mergifyio/gha-mergify-ci@v25
with:
action: scopes
integration-tests:
needs: detect-scopes
if: ${{ needs.detect-scopes.outputs.api == 'true' }}
uses: ./.github/workflows/integration-tests.yaml
secrets: inheritThe scopes action inspects the diff and returns a JSON map of scope names set to true or false, which each downstream job reads in its if condition. Add a final gate job using the wait-jobs action and make that one status required in your branch ruleset, so skipped jobs never look like missing coverage. Full setup is in the monorepo CI documentation, and the monorepo CI guide covers when this pays off and when it does not.
The same scopes then feed the merge queue. When Mergify assembles a batch it prefers pull requests that share scopes, so a batch tends to need one CI run rather than one per change, and a failure inside it is cheaper to bisect. If you do not have a queue yet, start with setting one up on GitHub, then come back and wire the scopes in. What to watch once it is running is covered in queue analytics.
How do you stop it creeping back?
CI gets slow the way weight gets gained, half a minute at a time, and nobody notices until the number is embarrassing. The fleet view carries a week-over-week comparison so you can answer "is CI getting worse" without building a dashboard for it. Pair it with the Slack integration for the job outcomes you care about, and with the PR summary comments that post what broke and where, so a regression gets caught by the person who introduced it instead of by the whole team a month later.
Re-run the ranking once a month. It takes ten minutes and the answer changes, because the job you fixed drops off the list and something you have never looked at moves up.
FAQ
Do I have to change my CI config to see job durations?
No. Job-level data comes from the CI provider itself, so enabling CI Insights on the repository is enough and the Jobs page fills in on the next workflow run. Test-level data is the part that needs a step in your workflow to upload JUnit reports.
Does this work outside GitHub Actions?
Jenkins and Buildkite are supported today and have their own setup guides, with CircleCI coming. Flaky detection and auto-retry behave the same way whichever CI produced the job, which is the point of putting several providers in one view.
Why sort by p95 instead of the average?
The average hides the runs that hurt. A job with a 4-minute median and a 30-minute p95 is a job that blocks somebody badly once or twice a day, and an average of 6 minutes makes it look fine. Sort by p95 to find pain, by median to find steady cost.
Should I fix the longest job or the most frequent one?
Multiply duration by executions and start at the top of that list. A 9-minute job that runs 60 times a day burns five times more machine time than a 52-minute nightly, and it is also the one sitting between your engineers and their merges.
My CI provider already has a dashboard. What does this add?
Health classification per job (healthy, flaky, or broken) rather than a raw list of runs, the same view across every provider you use, queue time split out from execution time, and the ability to act on what you find with auto-retry, quarantine, and scoped runs.
How much history do I need before the ranking means anything?
A couple of weeks. Percentiles need volume to settle, and a shorter window will over-weight whatever incident happened that Tuesday. Once you have a month, compare windows rather than absolute numbers.
Last updated: