DEV Community

Cover image for Stop Waiting for GitHub Actions: Make Your CI Faster Today
𝗝𝗼𝗡𝗻
𝗝𝗼𝗡𝗻

Posted on AI-assisted

Stop Waiting for GitHub Actions: Make Your CI Faster Today

You push a small README correction.

GitHub Actions starts from a clean runner, downloads every dependency, runs the linter, checks every type, executes the complete test suite, builds the application, and uploads an artifact nobody will download.

Before it finishes, you notice another typo and push again.

Now both workflows are running.

Nothing is technically broken. The workflow is simply doing more work than the change requires.

A slow CI pipeline is not only an infrastructure problem. It delays code review, interrupts concentration, consumes runner time, and makes every small pull request feel heavier than it should.

The good news is that the first improvements are usually small.

This guide starts with a deliberately ordinary Node.js workflow and improves it step by step. The same principles apply to other ecosystems, even when the exact setup and cache commands differ.

The goal is not the shortest possible workflow.

The goal is fast, trustworthy feedback with no unnecessary work.

Table of contents


Start with evidence, not guesses

Do not optimize a workflow because one step looks slow.

Open a representative run in the Actions tab and record:

  • total workflow duration,
  • queue time,
  • duration of each job,
  • duration of dependency installation,
  • duration of linting, type checking, tests, and builds,
  • how often runs are canceled or superseded,
  • cache hit and miss behavior,
  • artifact size and whether anyone downloads it,
  • repeated work across jobs.

Use several runs rather than one unusual result. A temporary package registry delay or cold runner can distort a single measurement.

A simple baseline is enough:

Dependency installation:  52 seconds
Lint and type check:       24 seconds
Tests:                    104 seconds
Build:                     47 seconds
Artifact upload:           18 seconds
Total workflow:           245 seconds
Enter fullscreen mode Exit fullscreen mode

Your numbers will be different. The important part is knowing where the time goes before changing the YAML.

Optimize feedback time as well as total time

Two workflows can have the same total duration but feel very different.

If a formatting error is reported after four minutes of testing, the developer waits four minutes for information that could have arrived in twenty seconds. Moving fast checks earlier may improve the development loop even if a successful run is not dramatically shorter.

Useful measurements include:

Time to first useful failure
Time to all required checks passing
Runner minutes consumed per pull request
Percentage of runs canceled as outdated
Enter fullscreen mode Exit fullscreen mode

A fast green run is useful. A fast, precise failure is often more useful.

The workflow we are fixing

Here is a common first version:

name: CI

on:
  push:
  pull_request:

jobs:
  ci:
    runs-on: ubuntu-latest

    steps:
      - name: Check out repository
        uses: actions/checkout@v5

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 24

      - name: Install dependencies
        run: npm install

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npm run typecheck

      - name: Test
        run: npm test

      - name: Build
        run: npm run build

      - name: Upload build
        uses: actions/upload-artifact@v5
        with:
          name: application-build
          path: dist
Enter fullscreen mode Exit fullscreen mode

This workflow is understandable, which is a good start. It also has several opportunities for improvement:

  • every push starts another run,
  • dependency downloads are not cached,
  • npm install may change the resolved dependency tree,
  • every check executes in one long sequence,
  • documentation-only changes run the full pipeline,
  • every successful run uploads a build,
  • there is no timeout,
  • permissions are implicit.

We will improve these one at a time.

Fix 1: cancel outdated runs

Suppose a pull request receives three commits in ten minutes. In most CI workflows, only the newest commit matters. Finishing tests for the first two revisions provides little value once a newer revision exists.

Add workflow-level concurrency:

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
Enter fullscreen mode Exit fullscreen mode

Full context:

name: CI

on:
  push:
    branches:
      - main
  pull_request:

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
Enter fullscreen mode Exit fullscreen mode

Runs sharing the same concurrency group will not continue in parallel. With cancel-in-progress: true, a new run cancels the older active run in that group.

Including github.workflow prevents unrelated workflows from accidentally sharing the same group. Including github.ref keeps different branches or pull requests separate.

When not to cancel

Cancellation is a good fit for validation of changing pull requests. It may be wrong for:

  • deployments that must complete in order,
  • database migrations,
  • release publishing,
  • stateful integration processes,
  • jobs that perform irreversible external actions.

For those cases, use a separate concurrency group and consider queueing rather than cancellation.

Do not apply one concurrency policy to every workflow without considering what interruption means.

Fix 2: cache dependency downloads

GitHub-hosted jobs start on clean runner images. Without caching, package managers repeatedly download the same dependency archives.

For npm, setup-node can manage the package-manager cache:

- name: Set up Node.js
  uses: actions/setup-node@v6
  with:
    node-version: 24
    cache: npm

- name: Install dependencies
  run: npm ci
Enter fullscreen mode Exit fullscreen mode

This caches npm's download cache, not the completed node_modules directory.

That distinction matters.

npm ci still reconstructs the dependency installation from the lockfile. It simply has a better chance of retrieving packages from a restored local cache instead of downloading every archive again.

Cache the package manager, not node_modules

Caching node_modules can look faster in a quick experiment, but it couples the cached directory to details such as:

  • operating system,
  • architecture,
  • Node.js version,
  • native module compilation,
  • package-manager behavior,
  • lifecycle scripts.

A package-manager cache is usually more portable and easier to reason about. The workflow should also remain correct on a cache miss.

Monorepos need the correct lockfile

If the lockfile is not in the repository root, declare the dependency path:

- name: Set up Node.js
  uses: actions/setup-node@v6
  with:
    node-version: 24
    cache: npm
    cache-dependency-path: apps/web/package-lock.json
Enter fullscreen mode Exit fullscreen mode

For multiple lockfiles:

cache-dependency-path: |
  apps/web/package-lock.json
  apps/api/package-lock.json
Enter fullscreen mode Exit fullscreen mode

A cache is only useful when its key changes with the dependency definition that matters.

Verify that the cache actually helps

After enabling caching, compare several runs:

  • the first run should usually miss and populate the cache,
  • later runs with the same lockfile should restore it,
  • a changed lockfile should create a new applicable cache entry,
  • dependency installation must still succeed when no cache exists.

If install time barely changes, the bottleneck may be lifecycle scripts, native compilation, network access outside the package manager, or work performed after download.

Fix 3: use deterministic installs

In CI, prefer:

npm ci
Enter fullscreen mode Exit fullscreen mode

over:

npm install
Enter fullscreen mode Exit fullscreen mode

npm ci expects a lockfile, removes an existing node_modules directory, and installs the dependency tree represented by the lockfile. It also fails when package.json and the lockfile disagree instead of silently updating the lockfile.

That gives CI a clearer job:

Verify the dependency state committed to the repository.

It also avoids a workflow that succeeds with one dependency resolution while developers or production use another.

Equivalent deterministic commands exist in other package managers. Use the command recommended for reproducible CI installs in your ecosystem.

Keep runtime versions explicit

Do not rely on whatever runtime happens to be preinstalled on the current runner image.

- uses: actions/setup-node@v6
  with:
    node-version: 24
    cache: npm
Enter fullscreen mode Exit fullscreen mode

If the project supports several active Node.js versions, test them intentionally with a matrix rather than accepting accidental variation.

Fix 4: put cheap failures first

A workflow should report obvious problems before starting expensive work.

A reasonable order for many projects is:

Install
  ↓
Formatting or linting
  ↓
Type checking
  ↓
Unit tests
  ↓
Integration tests
  ↓
Production build
Enter fullscreen mode Exit fullscreen mode

That does not mean every step must be sequential. It means the workflow should consider cost and diagnostic value.

Sequential approach

One job is simple and reuses one dependency installation:

steps:
  - uses: actions/checkout@v5

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

  - run: npm ci
  - run: npm run lint
  - run: npm run typecheck
  - run: npm test
  - run: npm run build
Enter fullscreen mode Exit fullscreen mode

Advantages:

  • one checkout,
  • one runtime setup,
  • one dependency installation,
  • easy logs,
  • later work stops after an earlier failure.

Disadvantage:

  • independent checks cannot run in parallel.

For small repositories, this may be the fastest and easiest design.

Gated jobs

If tests are expensive and linting commonly fails, use a cheap quality gate:

jobs:
  quality:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  test:
    needs: quality
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm test
Enter fullscreen mode Exit fullscreen mode

This avoids running tests after a quality failure, but each job checks out the repository and installs dependencies separately.

The trade-off is real. Measure it.

How should I choose between one job and several jobs?

Prefer one job when:

  • the repository is small,
  • dependency installation is significant,
  • checks are short,
  • a simple log is valuable,
  • later steps should stop after the first failure.

Consider several jobs when:

  • tasks are long and independent,
  • parallel work meaningfully reduces feedback time,
  • different jobs need different runners or permissions,
  • branch protection should show distinct required checks,
  • one cheap gate can prevent substantial expensive work.

Do not split jobs only because a longer YAML file looks more sophisticated.

Fix 5: parallelize only independent work

Parallel jobs can reduce wall-clock time, but they can also increase total runner usage.

Suppose linting takes one minute, tests take four minutes, and the build takes three minutes.

Sequential execution takes roughly eight minutes after setup. Running all three in parallel may finish in about four minutes, but each job repeats setup and dependency installation.

That can be a good trade when developer feedback is the priority. It can be wasteful when jobs are short or runner usage is constrained.

A balanced structure

Use one lightweight required job and parallelize the expensive independent work after it:

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  test:
    needs: quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm test

  build:
    needs: quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run build
Enter fullscreen mode Exit fullscreen mode

After quality succeeds, test and build can run at the same time.

Avoid unnecessary matrices

A matrix is valuable when the project genuinely supports several runtimes or platforms:

strategy:
  matrix:
    node-version: [22, 24]
Enter fullscreen mode Exit fullscreen mode

Every matrix combination creates another job. Do not test combinations your project does not claim to support.

For pull requests, it may be reasonable to test the primary runtime and reserve the full compatibility matrix for pushes to main or scheduled runs. Make that choice explicit and ensure changes cannot merge without the coverage your project actually requires.

Fix 6: avoid irrelevant runs

If a workflow validates only an application under src/, a change to unrelated documentation may not require the full test and build pipeline.

Path filters can narrow the trigger:

on:
  pull_request:
    paths:
      - "src/**"
      - "tests/**"
      - "package.json"
      - "package-lock.json"
      - "tsconfig.json"
      - ".github/workflows/ci.yml"
Enter fullscreen mode Exit fullscreen mode

For a monorepo, a service-specific workflow might use:

on:
  pull_request:
    paths:
      - "apps/api/**"
      - "packages/shared/**"
      - ".github/workflows/api-ci.yml"
Enter fullscreen mode Exit fullscreen mode

Path filters can create merge problems

GitHub warns that when a required workflow is skipped because of path or branch filtering, its associated check can remain pending and block the pull request.

Before adding filters, review:

  • branch protection rules,
  • required check names,
  • shared packages that affect several applications,
  • generated files,
  • root configuration,
  • workflow files themselves.

For complex monorepos, a small always-running classification job may be safer than completely skipping a required workflow. That job can determine which test jobs need to run while still producing a stable required check.

Be cautious with skip commands

Commit messages such as [skip ci] can suppress workflows triggered by push or pull_request, but skipped required checks may remain pending. A manual skip should not become the normal optimization strategy.

Use trigger design and job conditions that are understandable to the whole team.

Fix 7: stop uploading unused artifacts

Artifacts are useful for files that must survive after a job finishes:

  • packaged applications,
  • test reports,
  • screenshots from failed browser tests,
  • coverage reports,
  • debugging logs,
  • release candidates.

They are not free decoration for every successful run.

If nobody downloads the build from ordinary pull requests, do not upload it on every pull request.

Upload only on main:

- name: Upload build
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
  uses: actions/upload-artifact@v5
  with:
    name: application-build
    path: dist
    retention-days: 7
Enter fullscreen mode Exit fullscreen mode

Or upload diagnostic material only after failure:

- name: Upload test diagnostics
  if: failure()
  uses: actions/upload-artifact@v5
  with:
    name: test-diagnostics
    path: test-results
    retention-days: 7
Enter fullscreen mode Exit fullscreen mode

Cache and artifacts solve different problems

Use a cache for reusable data that can be regenerated, such as downloaded dependencies.

Use an artifact for output that someone or another job needs to inspect or consume.

A cache miss should make the workflow slower, not incorrect. Losing a required release artifact may be a real failure.

Choose retention deliberately. Keeping every temporary report for the maximum period creates storage without necessarily creating value.

Add limits and minimum permissions

A hung command should not consume runner time indefinitely.

Set a timeout for every job:

jobs:
  quality:
    runs-on: ubuntu-latest
    timeout-minutes: 10
Enter fullscreen mode Exit fullscreen mode

Use a value based on observed normal duration, with enough room for ordinary variance. A job that usually finishes in two minutes probably does not need a six-hour ceiling.

Make permissions explicit

A CI workflow that only reads repository content can often start with:

permissions:
  contents: read
Enter fullscreen mode Exit fullscreen mode

Place this at workflow level, then increase permissions only for the specific job that needs them.

Minimal permissions improve security more than speed, but they also make the workflow easier to audit. Optimization should not trade away safety for a few seconds.

Pin actions according to your security policy

Version tags are easy to read:

uses: actions/checkout@v5
Enter fullscreen mode Exit fullscreen mode

Security-sensitive organizations may require actions to be pinned to full commit SHAs. GitHub provides repository and organization policies for restricting which actions can run and requiring full-length SHA pinning.

Follow the policy appropriate to your project, and use automated dependency updates so action references do not become stale.

Treat caches as untrusted input

Caching is an optimization boundary, not a secret store.

Never place these in a cache path:

  • API keys,
  • access tokens,
  • environment files containing secrets,
  • signing keys,
  • production credentials,
  • unprotected deployment configuration.

GitHub warns that workflows able to read a cache restore its contents as-is. Fork-based pull requests and low-trust workflow events make cache permissions especially important, and poisoned caches can affect later trusted work.

In September 2026, GitHub made cache-mode generally available across plans. It lets workflows or jobs declare cache access as:

read       restore but do not save
write      restore and save
write-only save but do not restore
none       disable cache access
Enter fullscreen mode Exit fullscreen mode

The secure default depends on the event. Low-trust events receive restricted behavior, and explicitly granting write access can reintroduce cache-poisoning risk.

Do not add a write-capable cache mode to an untrusted event merely to avoid a cache miss. Read the current GitHub syntax documentation and grant only the access the job needs.

A faster compromised workflow is not an optimization.

Treat restored cache content as untrusted, keep secrets out of caches, and use the least cache access required by each event.

The complete improved workflow

The best final structure depends on your repository. The following example favors clear feedback and manageable complexity for a typical Node.js project.

name: CI

on:
  push:
    branches:
      - main
  pull_request:

permissions:
  contents: read

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

jobs:
  quality:
    name: Lint and type check
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - name: Check out repository
        uses: actions/checkout@v5

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Type check
        run: npm run typecheck

  test:
    name: Test
    needs: quality
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - name: Check out repository
        uses: actions/checkout@v5

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Upload test diagnostics
        if: failure()
        uses: actions/upload-artifact@v5
        with:
          name: test-diagnostics-${{ github.run_id }}
          path: test-results
          if-no-files-found: ignore
          retention-days: 7

  build:
    name: Build
    needs: quality
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - name: Check out repository
        uses: actions/checkout@v5

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build

      - name: Upload main-branch build
        if: github.event_name == 'push' && github.ref == 'refs/heads/main'
        uses: actions/upload-artifact@v5
        with:
          name: application-build-${{ github.sha }}
          path: dist
          if-no-files-found: error
          retention-days: 7
Enter fullscreen mode Exit fullscreen mode

What changed:

  • pushes to feature branches no longer duplicate pull request validation,
  • older runs for the same ref are canceled,
  • npm download caching is enabled,
  • npm ci produces deterministic installs,
  • cheap quality checks run before expensive jobs,
  • tests and builds run in parallel after the quality gate,
  • artifacts are uploaded only when useful,
  • failed tests can preserve diagnostics,
  • every job has a timeout,
  • repository permissions are explicit.

Is this always faster?

No.

For a small project, three separate npm ci executions may cost more than the parallelism saves. In that case, keep one job and retain the other improvements:

jobs:
  ci:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v6
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm test
      - run: npm run build
Enter fullscreen mode Exit fullscreen mode

The article cannot choose for your repository. Your measurements can.

Changes that look fast but often backfire

Caching everything

A huge, unstable cache may take longer to restore than the work it replaces. It also increases invalidation and security complexity.

Splitting every command into its own job

Parallel YAML is not automatically efficient. Each job has setup overhead and consumes runner time.

Skipping tests based on weak path rules

A root configuration or shared package may affect more applications than the filter suggests.

Removing verification to save time

A workflow that finishes quickly because it no longer checks important behavior is not improved.

Using broad restore keys without understanding them

A partial cache match can restore older content. That may be acceptable for package downloads, but risky for build outputs that depend on exact source, compiler, or environment state.

Running the full compatibility matrix on every draft commit

Test what is necessary for pull request feedback, then run broader compatibility checks at the stage where they provide value.

Uploading every output forever

Artifacts need a consumer and a retention policy.

A practical optimization checklist

Measure

  • [ ] Record total duration across several representative runs.
  • [ ] Identify the slowest steps and repeated setup.
  • [ ] Measure time to first useful failure.
  • [ ] Check queue time separately from execution time.
  • [ ] Review runner minutes and artifact storage.

Eliminate outdated work

  • [ ] Add an appropriate concurrency group.
  • [ ] Cancel superseded pull request runs.
  • [ ] Keep deployments and irreversible jobs on a safer policy.
  • [ ] Avoid duplicate push and pull request runs for the same feature branch.

Improve dependency setup

  • [ ] Use the deterministic install command for the package manager.
  • [ ] Cache package-manager downloads.
  • [ ] Include the correct lockfile in cache configuration.
  • [ ] Verify behavior on both cache hits and misses.
  • [ ] Keep secrets and sensitive files out of cache paths.

Improve feedback

  • [ ] Put cheap, precise checks before expensive work when useful.
  • [ ] Parallelize only independent tasks.
  • [ ] Keep job boundaries understandable.
  • [ ] Use the full compatibility matrix only where it adds value.
  • [ ] Preserve useful diagnostics after failure.

Reduce irrelevant execution

  • [ ] Review event and branch triggers.
  • [ ] Use path filters only after checking required-check behavior.
  • [ ] Include shared configuration and packages in relevant filters.
  • [ ] Do not make manual skip messages the main workflow strategy.

Control resources

  • [ ] Add realistic job timeouts.
  • [ ] Upload artifacts only when someone or another job needs them.
  • [ ] Set intentional artifact retention periods.
  • [ ] Use explicit minimum permissions.
  • [ ] Review action pinning and update policy.

The takeaway

A faster GitHub Actions workflow usually does not require faster hardware.

It requires less unnecessary work.

Start with the changes that have the clearest value:

Measure the current run
          ↓
Cancel outdated work
          ↓
Cache dependency downloads
          ↓
Use deterministic installs
          ↓
Report cheap failures early
          ↓
Parallelize only where it helps
          ↓
Skip irrelevant work carefully
          ↓
Retain only useful artifacts
Enter fullscreen mode Exit fullscreen mode

Then measure again.

Do not optimize for an impressive YAML file. Optimize for reliable feedback that arrives while the developer still remembers the change.

Which step consumes the most time in your current GitHub Actions workflow?

Explore the GitHub Actions documentation


Sources and further reading


Connect with Me

If you found this article helpful, let's connect!

Top comments (2)

Collapse
 
nyaomaru profile image
nyaomaru •

Great article, John! 😸
I really liked that you focused on reducing unnecessary work rather than just making the workflow look more β€œoptimized.”

The point about measuring first β€” especially β€œtime to first useful failure” β€” was a really good one. I also appreciated the caveats around caching, path filters, and parallel jobs. Those are exactly the areas where an optimization can easily backfire if we apply it blindly.

This actually made me want to revisit some of my own GitHub Actions workflows, including changelog-bot 😸

Thanks for putting this together! πŸ‘

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Thanks, I really appreciate that! 😸

That was exactly the point I was hoping to get across. It's surprisingly easy to make a workflow look more sophisticated while actually increasing complexity, runner usage, or maintenance cost.

"Time to first useful failure" has become one of my favorite metrics because it reflects how the workflow feels to developers, not just how impressive the final duration looks on a dashboard.

And yes, the caching, path-filtering, and parallelization examples came from seeing (and occasionally creating πŸ˜…) optimizations that looked great at first but turned out to have unexpected side effects.

I'd be curious to hear what you find if you revisit changelog-bot. Sometimes a fresh look at a workflow after a few months reveals a surprising amount of unnecessary work.

Thanks again for reading and for all the great work you put into the project!