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.
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.
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.
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:
- Build one immutable output.
- Test that output.
- Upload it as an artifact.
- Download the same artifact in the deployment job.
- 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.
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
- Add pull-request concurrency and cancel outdated runs.
- Configure dependency caching based on the lock file.
- Run lint, type checking, and unit tests independently.
- Split the large test suite into balanced shards.
- Use Docker BuildKit caching.
- Upload screenshots and detailed diagnostics only on failure.
- 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
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?
Does running jobs in parallel reduce GitHub Actions cost?
Should node_modules be cached?
Why did a path-filtered required check remain pending?
Should every test run on every pull request?
When are larger runners worth using?
Official References
- GitHub Actions: Workflow syntax
- GitHub Actions: Dependency caching reference
- GitHub Actions: Control workflow concurrency
- GitHub Actions: Matrix and job variations
- GitHub Actions: Store and share workflow artifacts
- GitHub Actions: Workflow artifacts and caching
- GitHub Actions: Reusable workflows
- GitHub Actions: Workflow visualization graph
- GitHub Actions: Using larger runners
- GitHub Actions: Self-hosted runners
- GitHub Actions: Secure use reference
- Official actions/checkout repository
- Official actions/setup-node repository
- Docker Build Push Action documentation

The Linqdev Editorial Team researches, reviews, and publishes practical content about cloud architecture, API security, DevOps workflows, and scalable software systems. Our guides are prepared using official documentation, reliable technical sources, and careful editorial review to provide clear, accurate, and useful information for developers and technology professionals.




