How to Reduce CI/CD Pipeline Build Times in GitHub Actions

CI/CD workflow optimization and automation for GitHub Actions

A shorter GitHub Actions run is useful only if it returns trustworthy feedback. Removing checks, skipping affected components, or interrupting state-changing jobs can make a workflow appear faster while increasing release risk.

This playbook treats optimization as a controlled workflow change: identify the delay, change one bottleneck, verify both performance and protection, then retain a clear rollback path. The configurations below are illustrative rather than measured results; adapt commands, action versions, cache paths, matrix sizes, and runner labels to the repository.

Set the target before changing YAML

“CI is slow” can describe several different constraints. Select the metric that matches the actual complaint instead of optimizing total duration by default.

Metric What it reveals Likely response
Queue time Delay before a runner starts Reduce obsolete runs or review runner capacity
Execution time Time spent inside jobs Cache dependencies, improve builds, or optimize tests
Time to first failure How quickly a developer receives actionable feedback Run fast, high-signal checks earlier or in parallel
Wall-clock duration Elapsed time until all required jobs finish Parallelize independent work or shard a dominant suite
Total runner usage Combined execution across jobs Remove repeated setup, excessive matrices, and irrelevant runs
Deployment lead time Time from approval to verified deployment Build once and deploy the already validated artifact

Parallel jobs may improve wall-clock duration while increasing total runner usage. A larger runner can shorten a CPU-bound build without affecting queue time. Cancellation saves capacity but does not make an individual job execute faster.

Capture a baseline and a rollback boundary

Review multiple recent pull-request, main-branch, release, and scheduled runs. Record job durations, slow steps, queue delay, cache results, artifact transfer time, repeated setup, and where failures usually occur. A single run can be distorted by a cold cache, unusual change size, or temporary queue conditions.

Before rollout, define repository-specific acceptance conditions. For example:

  • The required checks still run for every change that can affect their component.
  • A clean cache miss produces the same valid result as a cache hit.
  • All test shards collectively run the intended suite.
  • Deployment jobs still consume the approved artifact and retain their existing protections.
  • The selected timing metric improves across comparable runs without an unacceptable increase in runner usage or failures.

Keep the previous workflow revision available in version control. Most CI-only changes can be rolled back by reverting the workflow commit. Changes involving repository rules, required check names, runner infrastructure, or deployments also need those related settings restored.

Roll out optimizations from least disruptive to most structural

Make one category of change at a time. Combining cancellation, caching, path filters, sharding, and runner changes in one commit makes both diagnosis and rollback harder.

1. Cancel obsolete pull-request validation

When several commits are pushed to the same pull request, an older validation run may no longer be useful. A pull-request-specific concurrency group can cancel it:

name: Pull Request CI

on:
  pull_request:

concurrency:
  group: pr-${{ github.workflow }}-${{ github.event.pull_request.number }}
  cancel-in-progress: true

Verify this with a draft or test pull request by pushing a second commit while the first run is active. The older run should be cancelled and the newest commit should complete all required checks.

Do not apply this policy automatically to production deployments, database migrations, release publication, infrastructure changes, or other state-changing operations. Those workflows often need serialization rather than interruption. If deployment behavior is being redesigned as well, treat that as a separate change with an explicit recovery plan; see automating rollbacks for failed deployments.

2. Add dependency caching without making correctness depend on it

For Node.js, cache package-manager downloads and continue installing from the lock file:

- name: Check out repository
  uses: actions/checkout@v6

- name: Set up Node.js
  uses: actions/setup-node@v6
  with:
    node-version: 22
    cache: npm
    cache-dependency-path: package-lock.json

- name: Install dependencies
  run: npm ci

The lock file must participate in cache selection. A manual key may also need operating system, architecture, runtime version, package-manager version, or monorepo subproject boundaries.

Verify three cases: a first run with a miss, a repeated run with unchanged dependencies, and a run after changing the lock file. All must complete correctly. If stale or incompatible content appears, remove the caching configuration or narrow the key; the normal installation path should remain functional.

Never cache credentials, access tokens, signing keys, authenticated configuration, environment files containing secrets, or private package credentials.

3. Stop transferring artifacts that nobody consumes

A cache accelerates future work; an artifact stores or transfers a workflow output. Do not substitute one for the other.

Compiled packages needed by deployment are artifacts. Package-manager downloads are cache candidates. Screenshots, videos, and crash data may only be necessary after failure:

- name: Upload browser diagnostics
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: end-to-end-diagnostics
    path: |
      test-results/
      screenshots/
    retention-days: 5

After changing uploads, force a representative failure and confirm that the expected diagnostics remain available. Also inspect the artifact paths for secrets or private data.

4. Parallelize meaningful units, not individual commands

Linting, type checking, unit tests, and some security checks can run independently. Parallel jobs reduce feedback time, but each job receives an isolated runner and may repeat checkout, runtime setup, and dependency installation.

Keep tasks together when setup costs more than the task, one command consumes another command’s output, or separation requires a large intermediate artifact. Split a large test suite only when the test framework supports deterministic sharding and all shards together preserve coverage.

Illustrative pull-request workflow

name: Pull Request CI

on:
  pull_request:

permissions:
  contents: read

concurrency:
  group: pr-${{ github.workflow }}-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  quick-checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: package-lock.json
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  unit-tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      max-parallel: 4
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: package-lock.json
      - run: npm ci
      - name: Run test shard
        run: npm test -- --shard=${{ matrix.shard }}/4
      - name: Upload failure diagnostics
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-diagnostics-${{ matrix.shard }}
          path: test-results/
          retention-days: 5

  build:
    runs-on: ubuntu-latest
    needs: [quick-checks, unit-tests]
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: package-lock.json
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: production-build
          path: dist/
          retention-days: 7

The sharding syntax is framework-specific. Confirm that every test is assigned exactly as intended, compare shard durations, and check for shared-state collisions. Equal file counts do not guarantee balanced execution times. If one shard dominates or parallel execution introduces instability, reduce the shard count or revert to the previous test job while the suite is reorganized.

5. Restrict triggers only after testing required-check behavior

Path filters can prevent an API workflow from running for unrelated changes:

on:
  pull_request:
    paths:
      - "apps/api/**"
      - "packages/shared/**"
      - "package-lock.json"
      - ".github/workflows/api-validation.yml"

The difficult part is identifying every shared input. Root lock files, schemas, generated code, shared packages, build scripts, and workflow files may all affect a component.

A workflow skipped by path or branch filtering can leave an associated required check pending and block merging. Test documentation-only changes, component changes, shared-file changes, and workflow changes against the actual repository rules. If merge behavior is incorrect, restore the broad trigger first, then redesign the required-check arrangement separately.

6. Reduce matrices without silently reducing support

A matrix multiplies jobs. Pull-request validation may use a smaller set of environments while broader compatibility runs on a schedule or before release, but only if that placement matches the project’s support and release policy.

Question Waste signal Safer adjustment
Is the runtime supported? Jobs cover versions no longer supported Match the documented support policy
Must every combination run on each pull request? Small changes run the complete grid Keep essential PR coverage and run the broader grid at a defined release stage
Does the operating system alter behavior? An environment unrelated to users or deployment is repeated Retain systems the software actually supports
Is the Cartesian product meaningful? The matrix creates unsupported combinations Use explicit include and exclude entries

7. Build once when deployment can reuse the output

Repeated compilation in validation, packaging, and deployment wastes work and can result in deployment of an output different from the one previously examined. Where the release design permits it, build an immutable output, validate it, upload it, and have deployment download that same artifact. Verify its digest before release.

This is not automatically faster: transferring a very large artifact can take longer than rebuilding. Treat a build-once change as a release-path modification, not a harmless cache adjustment. Validate artifact contents, retention, permissions, and deployment recovery before making it required.

8. Optimize Docker layers before changing runner size

Copy stable dependency definitions before frequently changing source files so that source edits do not unnecessarily invalidate dependency layers:

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

Review .dockerignore so local dependencies, Git history, temporary output, test media, environment files, and credentials do not enter the build context. BuildKit caching can help when layers remain reusable, but its value depends on Dockerfile order, architecture, branches, and build arguments.

Failure modes and immediate rollback actions

Change Failure signal Immediate response
Concurrency cancellation A release or migration is interrupted Remove cancellation from state-changing workflows and restore serialized execution
Dependency cache Misses fail or stale dependencies appear Disable the cache or tighten its key; retain lock-file installation
Path filters A required check remains pending or affected code is skipped Restore the previous broad trigger and required-check configuration
Parallel tests Shared ports, databases, or files collide Reduce parallelism or return affected tests to a serial job
Test sharding Tests are omitted, duplicated, or badly imbalanced Revert to the complete suite and correct shard assignment
Artifact reduction Failures no longer have enough diagnostics Restore the required failure artifacts with narrowly defined paths
Build-once release Deployment cannot retrieve or verify the artifact Stop the rollout and restore the previously validated packaging path
Self-hosted runner Jobs rely on residual state or expose sensitive access Remove untrusted workloads and return them to an isolated approved runner

Runner changes are capacity decisions, not YAML shortcuts

Larger GitHub-hosted runners are candidates for CPU-, memory-, disk-, or architecture-constrained work. They may provide little benefit when external downloads, serial test logic, or unnecessary triggers dominate.

Self-hosted runners add responsibility for patching, isolation, credentials, scaling, image maintenance, and incident response. Persistent runners may retain local state, so workflows must not depend on undeclared files from earlier jobs. Untrusted pull-request code must not gain access to sensitive infrastructure. These concerns align with broader zero-trust controls for DevOps environments; runner provisioning should also be reviewed like other managed infrastructure, including the practices discussed in this infrastructure-as-code tooling guide.

Post-change verification sequence

  1. Inspect workflow structure: confirm job dependencies and required check names in the visualization graph.
  2. Run a cache miss: verify the workflow succeeds without reusable cache data.
  3. Run a cache hit: confirm restoration occurs and the normal install command still runs.
  4. Exercise failure paths: confirm fast checks report promptly and diagnostic artifacts appear only where intended.
  5. Exercise trigger boundaries: test component, shared-file, documentation, and workflow-file changes.
  6. Push a superseding commit: confirm only obsolete validation is cancelled.
  7. Compare equivalent runs: review the target metric as well as total jobs, runner usage, and failure behavior.
  8. Retain rollback readiness: keep the previous workflow and related repository-rule settings identifiable until the change is accepted.

The useful endpoint is not the smallest number in the Actions interface. It is a workflow that returns relevant feedback sooner, avoids repeated work, and preserves the repository’s testing, security, and release guarantees.

Official references