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

CI/CD workflow optimization and automation for GitHub Actions

Reducing GitHub Actions build times is not about removing tests until the workflow becomes fast. It is about eliminating repeated work, running checks at the correct stage, and shortening the time between a developer pushing code and receiving useful feedback.

A workflow can feel slow for several different reasons. It may spend too long waiting for a runner, installing dependencies, compiling the same project in several jobs, executing a large test suite sequentially, transferring oversized artifacts, or running workflows that the change did not require.

These problems should not be treated as one metric. Queue time, execution time, time to first failure, total runner usage, and deployment duration describe different parts of the developer experience.

This guide presents a practical process for measuring GitHub Actions performance, improving dependency caching, cancelling outdated runs, parallelizing independent work, controlling test matrices, optimizing Docker builds, reducing artifact transfer, and selecting runner capacity without weakening release protection.

Optimization scope The examples in this article are illustrative. Commands, action versions, cache paths, test-sharding syntax, runner availability, billing, and security requirements vary by repository. Test workflow changes in a branch before applying them to production deployments or required pull-request checks.

Last reviewed: July 16, 2026. GitHub Actions, official actions, runner images, billing rules, and third-party tools change over time. Confirm current behavior in the official documentation before copying a workflow into production.

Define What “Faster” Means

A team may say that CI is slow while referring to completely different problems.

Metric What It Measures Typical Improvement
Queue time Time between workflow creation and runner assignment Runner capacity, autoscaling, concurrency policy, or fewer unnecessary runs
Execution time Time spent running steps after a runner starts Caching, faster tests, optimized builds, or a more suitable runner
Time to first failure How quickly a developer receives actionable failure feedback Run fast, high-signal checks early or in parallel
Wall-clock duration Elapsed time until all required jobs finish Parallel jobs, test sharding, progressive stages, and reduced dependencies
Total runner usage Combined execution consumed across jobs Avoid duplicate setup, unnecessary matrices, and redundant workflows
Deployment lead time Time from an approved change to a verified deployment Reuse tested artifacts and separate validation from deployment

Parallelizing four jobs may reduce wall-clock duration while increasing total runner usage. A larger runner may shorten compilation without reducing queue time. Cancelling an outdated run may save minutes but does not make an individual job faster.

Choose the metric that corresponds to the actual complaint before editing the workflow.

Step 1: Measure the Current Workflow

Begin with recent workflow runs representing typical pull requests, main-branch merges, releases, and scheduled jobs.

For each workflow, record:

  • Time waiting for a runner.
  • Duration of every job.
  • The slowest individual steps.
  • Cache hit or miss behavior.
  • Artifact upload and download duration.
  • How many jobs run in parallel.
  • How many jobs repeat the same checkout, setup, installation, or build.
  • How often runs are cancelled or superseded by newer commits.
  • Failure frequency and the step where failures normally appear.
Pipeline Measurement Flow
Collect Recent Runs
Separate Queue and Execution
Find Repeated Work
Change One Bottleneck
Compare Results

Create a Baseline

Use more than one workflow run. A single run may be affected by a cold cache, a temporary service delay, a queue spike, or unusually small test data.

A simple baseline may include:

  • Median pull-request duration.
  • Slower-percentile duration, such as a representative high-duration run.
  • Median queue time.
  • Time to first useful failure.
  • Cache hit rate.
  • Total jobs and runner usage per pull request.

Do not claim an optimization based only on one unusually fast run after the change.

Step 2: Cancel Outdated Workflow Runs

When a developer pushes several commits to the same pull request, earlier workflow runs may no longer provide useful information. Allowing all of them to finish consumes runner capacity and may delay the newest commit.

GitHub Actions supports concurrency groups that can cancel an older in-progress run when a newer run enters the same group.

name: Pull Request CI

on:
  pull_request:

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

For workflows that may also run outside pull requests, a more general group can use the pull-request number when available and fall back to the Git reference:

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

Do Not Cancel Every Workflow Automatically

Cancellation is normally useful for validation workflows where only the newest commit matters. It may be unsafe for:

  • Database migrations already in progress.
  • Production deployments.
  • Release publishing.
  • Infrastructure changes.
  • Irreversible external operations.

Deployment concurrency should usually serialize releases rather than interrupt an active deployment without a tested cancellation procedure.

Step 3: Cache Dependencies Correctly

Dependency installation is frequently one of the largest sources of repeated work. A cache can reuse package-manager files between workflow runs when the relevant dependency definition has not changed.

For Node.js, an explicit setup may look like:

steps:
  - 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 dependency lock file should participate in the cache key. When dependencies change, the workflow should create or restore an appropriate cache rather than reusing incompatible content.

Cache Package-Manager Data, Not Arbitrary State

For npm, caching the package manager’s download cache is generally safer than treating an existing node_modules directory as a permanent build product.

The normal install command should still verify and reconstruct the dependency tree from the lock file.

Cache Keys Need the Correct Boundaries

A manual cache key may include:

  • Operating system.
  • CPU architecture when relevant.
  • Language or runtime version.
  • Package-manager version when compatibility requires it.
  • A hash of the lock file.
  • Repository subproject in a monorepo.

An illustrative manual cache:

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

Do Not Put Secrets in a Cache

Cache paths must not contain:

  • Access tokens.
  • Cloud credentials.
  • Private signing keys.
  • Authenticated configuration files.
  • Environment files containing secrets.
  • Private package credentials.

A workflow should still work after a cache miss. Caches are an optimization, not the only copy of required build data.

Cache and Artifact Are Different

Feature Cache Artifact
Main purpose Speed up future jobs or workflow runs Store and transfer a workflow output
Typical content Downloaded dependencies or reusable intermediate files Compiled application, reports, screenshots, or release package
Availability May miss or be evicted; workflow must rebuild it Associated with a workflow run and retention policy
Correctness role Optimization only Can be the tested output passed into deployment

Step 4: Run Only Work That Is Relevant

A documentation change does not necessarily need a full application build, browser test suite, Docker image, and infrastructure plan.

GitHub Actions supports branch and path filters:

name: API Validation

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

Include all shared files capable of affecting the component. A backend workflow may need to run when a root lock file, shared package, schema, code-generation template, or workflow file changes.

Check required-status behavior before using path filters When a workflow is skipped because of branch or path filtering, its associated check can remain pending. If that workflow is required by a repository rule, the pull request may be blocked. Test the complete ruleset and merge behavior before relying on filters.

Separate Workflows by Purpose

Workflow Typical Trigger Typical Work
Fast pull-request checks Every relevant pull request Linting, type checks, unit tests, and a production build
Integration validation Relevant code or schema changes Database, queue, contract, and service tests
Broad compatibility Schedule, release candidate, or explicit request Multiple runtimes, operating systems, and optional configurations
Deployment Approved merge, tag, or manual dispatch Use tested artifacts, deploy, verify, and record the release

Step 5: Run Independent Jobs in Parallel

Jobs without a dependency defined through needs can run independently. This is useful for checks such as linting, type checking, unit testing, and certain security scans.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm run lint

  typecheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm run typecheck

  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm test

This structure improves time to feedback, but each job starts in an isolated runner environment and repeats some setup. Compare the saved wall-clock time with the additional runner usage and maintenance.

Do Not Split Every Command into a Job

Keep tasks together when:

  • Setup costs more than the task itself.
  • One task requires output produced by the previous task.
  • The jobs would need to transfer a large intermediate artifact.
  • The separation makes failures harder to diagnose.
  • The total runner usage increases without meaningful feedback improvement.

Step 6: Shard Large Test Suites

When a test suite dominates workflow duration, caching alone will not solve the problem. The tests must be reorganized or executed across controlled shards.

The exact sharding command depends on the test framework. An illustrative matrix may look like:

jobs:
  test:
    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

      - run: npm ci

      - name: Run test shard
        run: npm test -- --shard=${{ matrix.shard }}/4

Use a balancing method that prevents one shard from containing most of the slow tests. Equal file counts do not always create equal execution time.

Choose fail-fast Intentionally

With matrix jobs, fail-fast behavior can cancel remaining non-experimental jobs after one failure. This may save usage, but it can also hide failures in other supported configurations.

Use fail-fast when one shared failure makes the remaining results unhelpful. Disable it when the team needs the complete compatibility picture from every matrix entry.

Step 7: Reduce Test Cost Without Removing Protection

Separate tests according to the kind of risk they detect.

Test Type Recommended Placement Optimization Direction
Unit tests Early pull-request validation Keep deterministic, isolated, and fast
Integration tests Relevant service or schema changes Reuse controlled service setup and parallelize safely
End-to-end smoke tests Pull requests or preview deployments for critical paths Keep a small high-value set
Full end-to-end suite Release, schedule, or high-risk change Shard by historical duration and remove duplication
Compatibility matrix Release or scheduled workflow Test only officially supported combinations

Address Flaky Tests Directly

Automatically rerunning an entire suite can hide unstable tests and significantly increase build duration.

Track flaky tests separately and record:

  • Failure frequency.
  • Environment where they fail.
  • Whether retries change the result.
  • The team responsible for fixing them.
  • A deadline for removal from quarantine.

A quarantined test should not become a permanently ignored test.

Step 8: Build Once When the Output Is Reusable

Compiling the same application separately in validation, packaging, and deployment jobs can waste time and create a supply-chain problem: the deployed output may not be the exact output that passed the earlier tests.

When appropriate:

  1. Build one immutable output.
  2. Test that output.
  3. Upload it as an artifact.
  4. Download the same artifact in the deployment job.
  5. Verify its digest before release.
jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm run build

      - name: Upload tested build
        uses: actions/upload-artifact@v4
        with:
          name: web-application
          path: dist/
          retention-days: 7

  deploy:
    runs-on: ubuntu-latest
    needs: build

    steps:
      - name: Download tested build
        uses: actions/download-artifact@v4
        with:
          name: web-application
          path: dist/

      - name: Deploy
        run: ./scripts/deploy.sh dist/

This approach is most useful when the build is expensive and its output is reasonably small. Uploading and downloading a very large artifact can cost more time than rebuilding it.

Step 9: Upload Only Useful Artifacts

Artifacts may include compiled packages, coverage reports, browser screenshots, test videos, crash dumps, logs, and security reports.

Review whether each artifact is needed on successful runs.

Failure diagnostics can be uploaded only when a job fails:

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

Avoid uploading:

  • Complete dependency directories without a downstream need.
  • Duplicate reports from every shard.
  • Large videos for successful browser tests.
  • Build folders that no later job uses.
  • Logs that already exist clearly in the workflow run.
  • Files containing secrets or private customer data.

Step 10: Optimize Docker Builds

Docker build performance depends heavily on layer reuse and build context.

See also  Best Infrastructure as Code (IaC) Tools for Terraform Environments

Order the Dockerfile for Stable Layers

Copy dependency files before frequently changing application source:

FROM node:22-alpine AS build

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

If source files are copied before dependency installation, every source-code change may invalidate the dependency layer.

Reduce the Build Context

Use a reviewed .dockerignore file to exclude unnecessary content such as:

  • Local dependency directories.
  • Git history.
  • Temporary files.
  • Test screenshots and videos.
  • Local build output.
  • Environment files and credentials.

Use BuildKit Cache Intentionally

An illustrative GitHub Actions build can use the GitHub Actions cache backend:

steps:
  - uses: actions/checkout@v6

  - name: Set up Docker Buildx
    uses: docker/setup-buildx-action@v3

  - name: Build container image
    uses: docker/build-push-action@v6
    with:
      context: .
      push: false
      tags: example/application:${{ github.sha }}
      cache-from: type=gha
      cache-to: type=gha,mode=max

Cache usefulness depends on Dockerfile stability, branch behavior, architecture, build arguments, and how often layers change.

Step 11: Review Matrix Builds

A matrix multiplies jobs. This is valuable when each combination represents a supported environment, but expensive when combinations exist only because they are easy to add.

For a private application deployed on one runtime, pull-request checks may need only that runtime. A broader matrix can run before release or on a schedule.

Question Waste Signal Better Direction
Is this version supported? Tests run for runtimes the project no longer supports Match the published support policy
Must it run on every pull request? Every small change runs the complete compatibility grid Use a minimal PR matrix and broader release validation
Does the operating system change behavior? A Linux-only service is tested repeatedly on unrelated systems Test systems that users or deployments actually require
Is every combination meaningful? The Cartesian product creates unsupported combinations Use explicit include and exclude entries

Step 12: Reuse Workflow Logic Without Assuming It Is Faster

Reusable workflows and composite actions reduce duplication and make setup consistent across repositories.

They help with:

  • Maintaining one approved build process.
  • Updating action versions centrally.
  • Applying consistent permissions.
  • Preventing copied workflows from drifting apart.
  • Standardizing cache and artifact behavior.

Reusing YAML does not automatically reduce runtime. The called workflow still performs its configured jobs and steps. Its main benefit is consistency and maintainability, which can prevent duplicated or outdated work over time.

Step 13: Select the Right Runner

Optimization should come before simply purchasing more resources. However, a correctly structured workflow may still be limited by CPU, memory, disk throughput, architecture, or runner availability.

Larger GitHub-Hosted Runners

Larger runners can help with:

  • Large code compilation.
  • Mobile application builds.
  • Memory-intensive test suites.
  • Large container builds.
  • Monorepo tasks that genuinely use several CPU cores.

Measure the change. A job dominated by external downloads or a serial test runner may not improve significantly with more CPU.

Self-Hosted Runners

Self-hosted runners provide control over hardware, installed software, network access, and scaling. They also make the organization responsible for maintenance, isolation, updates, credentials, capacity, and incident response.

A persistent self-hosted runner may keep local state between jobs. Do not depend on undeclared files from a previous run, and do not allow untrusted pull-request code to access sensitive infrastructure.

Ephemeral runners can reduce cross-job contamination, but they still require secure image management and autoscaling.

Worked Example: Reducing a Pull-Request Workflow

Consider an illustrative Node.js service with this initial workflow:

  • Two minutes waiting for a runner.
  • Three minutes installing dependencies.
  • One minute linting.
  • Two minutes type checking.
  • Eight minutes running tests.
  • Four minutes building a container.
  • Two minutes uploading reports and build files.

The workflow performs every step sequentially and continues running when a newer commit is pushed.

Illustrative Changes

  1. Add pull-request concurrency and cancel outdated runs.
  2. Configure dependency caching based on the lock file.
  3. Run lint, type checking, and unit tests independently.
  4. Split the large test suite into balanced shards.
  5. Use Docker BuildKit caching.
  6. Upload screenshots and detailed diagnostics only on failure.
  7. Move the complete compatibility matrix to a scheduled or release workflow.
Area Before Illustrative Result
Dependency setup Every run downloads all packages Package downloads are commonly restored from cache
Feedback Lint failures appear after previous setup and work Fast independent checks report sooner
Tests One long sequential suite Balanced shards reduce wall-clock test duration
Docker build Layers are rebuilt on most commits Stable dependency and BuildKit layers are reused
Outdated runs Every pushed commit continues running Older validation runs are cancelled

The exact time saved depends on repository size, cache hit rate, test distribution, runner availability, and network conditions. The example demonstrates the method rather than promising a universal result.

A Practical Optimized Workflow

The following workflow combines several techniques. Test commands and action versions must be adapted to the repository.

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:
    name: 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:
    name: Unit tests — shard ${{ matrix.shard }}
    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:
    name: Production 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

      - name: Upload tested build
        uses: actions/upload-artifact@v4
        with:
          name: production-build
          path: dist/
          retention-days: 7

Security Controls Must Remain Intact

Do not reduce pipeline duration by:

  • Exposing secrets to pull requests from forks.
  • Replacing pinned dependencies with unreviewed scripts.
  • Granting broad write permissions to every job.
  • Caching credentials.
  • Skipping required security checks on sensitive changes.
  • Deploying an artifact different from the artifact that was tested.
  • Allowing untrusted code to execute on a sensitive self-hosted runner.

Set Permissions Explicitly

Start with the minimum token access required by the workflow:

permissions:
  contents: read

Grant additional permissions only to jobs that require them.

Review Third-Party Actions

For higher-security workflows, review action source, ownership, release practices, and requested permissions. Pin third-party actions according to the organization’s supply-chain policy.

Common GitHub Actions Optimization Mistakes

Mistake Possible Result Better Direction
Optimizing without a baseline The team changes YAML without knowing whether it helped Measure queue, steps, wall time, and runner usage first
Caching broad directories Incompatible or sensitive files are restored Cache documented package-manager or build data with precise keys
Using artifacts as dependency caches Every run spends time transferring unnecessary files Use cache for reuse and artifacts for workflow outputs
Splitting every step into a job Setup and transfer overhead increases total usage Parallelize meaningful independent units
Running the full matrix on every change Job count and queue time multiply Use a minimal PR matrix and broader release checks
Ignoring required-check behavior Path-filtered pull requests remain blocked in Pending state Test path filters with the repository ruleset
Uploading every diagnostic on success Artifact transfer becomes a large part of the workflow Upload failure diagnostics conditionally
Buying larger runners first An inefficient workflow becomes more expensive Remove waste, then benchmark runner changes
Removing security checks Pipeline duration improves while release risk increases Move expensive checks to the appropriate stage without silently removing protection

Production Optimization Checklist

Before considering the workflow optimized, confirm:
✓ Queue time and execution time are measured separately
✓ A baseline exists for normal pull requests
✓ Outdated validation runs are cancelled safely
✓ Cache keys include the required compatibility boundaries
✓ No secret is stored in a cache or artifact
✓ Workflows run only for relevant events and files
✓ Required-check behavior was tested with path filters
✓ Independent high-value checks run in parallel
✓ Test shards have reasonably balanced durations
✓ Flaky tests are tracked rather than hidden by unlimited retries
✓ Matrix entries match the supported environments
✓ Artifacts contain only outputs needed later
✓ Docker layers and build context are reviewed
✓ The deployed artifact is the artifact that passed validation
✓ Runner upgrades are supported by benchmarks
✓ Permissions and security checks remain intact

When to Request Specialist Support

Involve an experienced DevOps, platform, security, or build engineer when:

  • Workflows deploy directly to production.
  • Self-hosted runners process untrusted contributions.
  • Several repositories duplicate complex release logic.
  • Builds depend on private networks or sensitive credentials.
  • Queue time remains high despite reducing unnecessary runs.
  • The repository has a large monorepo or complex dependency graph.
  • Mobile, machine-learning, or native builds require specialized hardware.
  • Artifact provenance and software supply-chain requirements apply.
  • The team cannot determine whether a speed improvement weakens release protection.

Conclusion

Reducing GitHub Actions pipeline duration starts with measurement. Queue time, execution time, time to first failure, runner usage, and deployment lead time should not be treated as one number.

Cancel outdated validation runs, configure precise dependency caches, and avoid running workflows on irrelevant changes. Parallelize independent high-value checks, but account for repeated setup and additional runner usage.

When tests dominate the workflow, reorganize and shard them instead of only changing YAML. When container builds dominate, improve Docker layer reuse and build context. When artifact transfer dominates, upload only the outputs that later jobs or developers actually need.

Use larger or self-hosted runners only after confirming that the workload is resource-bound and that the operational and security responsibilities are understood.

A successful optimization provides faster feedback while preserving the same or stronger confidence in the release. The goal is not the smallest number displayed in the Actions tab. It is a reliable delivery process that avoids wasting developer time and runner capacity.

Frequently Asked Questions

What should I optimize first in GitHub Actions?
Measure recent runs and identify the largest source of delay. Dependency caching is useful when installation dominates, while test sharding helps when one large suite dominates. Runner capacity matters when queue time or compute resources are the actual bottleneck.
Does running jobs in parallel reduce GitHub Actions cost?
Not necessarily. Parallel jobs can reduce wall-clock duration while using a similar or greater total amount of runner time. Use parallelism when faster feedback or release completion justifies the additional job overhead.
Should node_modules be cached?
Package-manager download caches are generally a safer starting point. The normal installation command should reconstruct and verify dependencies from the lock file. Directly caching installed dependency trees requires careful compatibility and lifecycle controls.
Why did a path-filtered required check remain pending?
A workflow skipped by branch or path filtering can leave its check in a pending state. When repository rules require that check, the pull request may remain blocked. Test filters together with branch protection or rulesets before deployment.
Should every test run on every pull request?
Every pull request should receive enough testing to protect the repository. Fast unit tests and focused smoke checks commonly run on pull requests, while broader compatibility or long end-to-end suites may run before release, on a schedule, or when relevant high-risk files change.
When are larger runners worth using?
They are most useful when benchmarks show that jobs are constrained by CPU, memory, disk, or available runner capacity. They are less useful when the workflow is dominated by unnecessary triggers, serial waits, external downloads, or inefficient tests.

Official References

Editorial note: The timings, repository structure, test commands, action versions, cache paths, matrix sizes, and workflow examples in this guide are illustrative. Production workflows must be adapted to the repository, supported runtimes, security policy, runner environment, billing plan, and deployment process.