Skip to main content

GitHub Actions Optimization: Cut CI Time by 50% (Real Examples)

· 8 min read
Artur Pan
CTO & Co-Founder at PanDev

A 14-minute CI pipeline isn't just 14 minutes of waiting. GitHub Octoverse 2024 reported that the median enterprise repository now runs a pull request through CI 4.2 times before merge: retries, pushes after review, fixing flaky tests. That's nearly an hour of compute per PR. On a team shipping 200 PRs a week, the CI bill buys you nothing and the context-switch tax costs you a senior developer's Thursday.

This is a how-to. Six steps that consistently cut GitHub Actions CI time by 50%+ on real repos we've helped optimize. No theory; each step has a patch you can adapt.

{/* truncate */}

The problem

Most CI pipelines grow by accretion. A junior adds a test workflow. A senior adds a lint. A release engineer adds a security scan. Nobody holds a budget for total CI time, so it climbs until someone on-call complains about the Thursday afternoon queue.

CNCF's 2024 DevOps survey found 32% of teams spend more than 20 minutes per CI run on monorepos. UC Irvine's Gloria Mark work on attention shows a developer waiting on CI for 15+ minutes routinely loses re-focus after the wait. This aligns with our own data: PRs bounced from CI >2 times show a 23% longer cycle time than first-pass PRs, even after normalizing for size.

The costs compound:

CostTypical magnitude
GitHub Actions minutes (paid plans)$0.008/min × thousands of runs = real dollars
Developer wait time10–20 min × 4 retries × 200 PR/week = 130+ dev-hours/week
Context-switch taxPer Gloria Mark's research, up to 23 min to fully re-engage
Flakiness attributionEngineers stop trusting CI; "retry until green" becomes the pattern

A CI slower than 5 minutes on a medium repo is a productivity problem pretending to be an infra problem.

The framework: 6 steps

Step 1. Measure the baseline

Before cutting, know what's slow. GitHub Actions already surfaces duration per job and step. Pull the last 30 days of your main CI workflow and rank jobs by median duration.

# Use the GitHub CLI to dump workflow runs for the last 30 days
gh run list --workflow=ci.yml --limit 200 --json databaseId,displayTitle,conclusion,createdAt,updatedAt > runs.json

Three numbers to have before Step 2:

  • Median end-to-end workflow duration
  • p95 workflow duration (matters more than median for developer pain)
  • Top 3 jobs by time consumed

Without these numbers you'll cut the wrong things. A team we worked with spent a week parallelizing tests, which wasn't the bottleneck. The docker build was.

Step 2. Cache aggressively (the biggest single win)

Dependency install and build artifacts are usually 40–60% of CI time. GitHub's actions/cache@v4 is the fastest ROI change.

- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-

- name: Cache Maven dependencies
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}

- name: Cache Docker layers
uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max

The mistake most teams make: caching the output directory (dist/, target/) instead of the dependency directory. You want to cache what's expensive to fetch, not what's cheap to rebuild from sources.

Gain we typically see: 30–50% on JS-heavy repos, 25–40% on Maven/Gradle, 40–60% on Docker builds.

Step 3. Parallelize with matrix strategies

Sequential jobs are lazy CI. If you have 4 test suites that don't depend on each other, run them in parallel.

jobs:
test:
strategy:
matrix:
suite: [unit, integration, e2e, contract]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test -- --suite=${{ matrix.suite }}

Two gotchas:

  • Don't over-parallelize. Matrix of 20 gives you 20 runner startup costs (~30s each). Over 8 matrix entries, startup eats the gain.
  • Flaky tests amplify. A 1% flaky test rate per suite turns into ~4% flake rate on a matrix of 4 if any failure fails the PR. Fix flakes before parallelizing.

Step 4. Split heavy jobs; run cheap checks first

Don't block a lint failure behind a 10-minute test suite. Gate the expensive jobs on cheap ones.

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint

test:
needs: lint # only runs if lint passes
strategy:
matrix:
suite: [unit, integration]
runs-on: ubuntu-latest

The dev loop is: push → lint fails in 30s → fix → push. That's a 2-minute turnaround instead of 12 minutes of waiting to find out you left a console.log in.

Step 5. Prune test runs with change detection

Monorepos benefit hugely here. If a PR only touches services/billing/**, don't run the services/auth tests.

jobs:
changes:
runs-on: ubuntu-latest
outputs:
billing: ${{ steps.filter.outputs.billing }}
auth: ${{ steps.filter.outputs.auth }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
billing:
- 'services/billing/**'
auth:
- 'services/auth/**'

test-billing:
needs: changes
if: needs.changes.outputs.billing == 'true'
runs-on: ubuntu-latest
steps: [...]

Be careful: path filters that skip full regression suites before merge to main are a common source of "green PR, broken main." Gate path-filter skip on PRs, but run full suite on main pushes. That's the safe pattern.

Step 6. Monitor and hold the line

The hardest step. Optimizations decay: new workflows get added, dependencies grow, tests accumulate. Without a budget, you'll be back at 14 minutes in six months.

Set a hard ceiling. Example: CI p95 must stay under 8 minutes. Add a workflow that fails if it exceeds the budget for two consecutive weeks.

Teams using engineering-metrics platforms get this for free. PanDev Metrics tracks CI job durations alongside other delivery metrics and surfaces the trend in weekly dashboards. Without that, someone needs to set a monthly calendar reminder to check the numbers. That person always forgets.

CI optimization playbook flow: measure, cache, parallelize, split, prune, monitor Six steps in order. Measure first so you cut what's actually slow.

Common mistakes to avoid

MistakeWhy it hurtsFix
Caching output instead of dependenciesCache misses on every PR; dependencies re-downloaded every timeCache ~/.npm, ~/.m2, node_modules, not dist/
Running npm install instead of npm ciNon-deterministic, slower, writes to lockfileAlways npm ci in CI
Matrix of 20+ entriesRunner startup cost dominatesKeep matrix ≤8, split into separate workflows if more
Full test suite on every PR in a monorepo80% of tests are irrelevant to the diffPath filters, then full suite on main
No timeout on jobsStuck jobs burn billing minutes silentlytimeout-minutes: 15 on every job
Running on ubuntu-latest for simple scriptsPulls in a heavy image for a 2-line taskUse smaller runners or faster images where possible
Ignoring workflow concurrencyMultiple runs of same branch pile upconcurrency group with cancel-in-progress: true

That last one, concurrency, is the single laziest gain most teams miss.

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

One line. Cancels the in-flight run when a new push lands. On active PRs, saves 20–30% of compute minutes.

The checklist (copy and use)

  • Baseline measured: median + p95 duration + top 3 jobs by time
  • Dependency caches configured (npm, maven, docker layers)
  • Tests parallelized with matrix (max 8)
  • Cheap checks (lint, format) gate expensive checks (tests, build)
  • Path filters used for monorepo; full suite on main
  • concurrency with cancel-in-progress: true on PR workflows
  • timeout-minutes set on every job
  • Flaky tests fixed, not retried (retry hides them)
  • CI duration budget published; alert on p95 > budget

How to measure success

Four numbers, tracked weekly:

  • Median CI duration. Target: −50% within 4 weeks of applying the checklist.
  • p95 CI duration. Target: −40%. p95 matters more for developer pain.
  • CI retry rate per PR. Target: under 1.5x. Above 2x means flakes or incorrect gating.
  • Compute minutes per week. Target: −30% (you'll cache a lot of re-fetches).

Two of those four, median duration and retry rate, correlate directly with developer cycle time. Teams cutting CI time by 50% typically see cycle time drop 10–15% within a quarter, because the loop gets tighter.

When this framework doesn't fit

Two cases:

  • Matrix-dependent testing that's inherently sequential. Some integration suites can't parallelize because they share a test database. Fix the test infrastructure first (ephemeral DBs, test containers), then come back.
  • Very small repos (under 5 minutes CI). Don't optimize below 3 minutes. The effort isn't worth it. Use the engineer-hours elsewhere.

The counterintuitive finding

Teams that invest 2 weeks in CI optimization recover that investment in 6–8 weeks of saved developer time, according to our data from optimizing with 40+ customer teams. It's the highest-ROI week you can spend that isn't shipping features. Most teams never do it because there's no single owner: CI lives between DevOps, platform, and whoever broke it last. Assign it to one person, give them two weeks, measure the before/after. Done.

Try it yourself — free

Connect your IDE plugin in 2 minutes and see your real metrics. No credit card, no commitment.

Try Free