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.
Table of contents
- Start with evidence, not guesses
- The workflow we are fixing
- Fix 1: cancel outdated runs
- Fix 2: cache dependency downloads
- Fix 3: use deterministic installs
- Fix 4: put cheap failures first
- Fix 5: parallelize only independent work
- Fix 6: avoid irrelevant runs
- Fix 7: stop uploading unused artifacts
- Add limits and minimum permissions
- Treat caches as untrusted input
- The complete improved workflow
- A practical optimization checklist
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
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
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
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 installmay 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
Full context:
name: CI
on:
push:
branches:
- main
pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
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
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
For multiple lockfiles:
cache-dependency-path: |
apps/web/package-lock.json
apps/api/package-lock.json
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
over:
npm install
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
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
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
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
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.
Prefer one job when: Consider several jobs when: Do not split jobs only because a longer YAML file looks more sophisticated.How should I choose between one job and several jobs?
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
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]
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"
For a monorepo, a service-specific workflow might use:
on:
pull_request:
paths:
- "apps/api/**"
- "packages/shared/**"
- ".github/workflows/api-ci.yml"
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
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
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
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
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
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
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.
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
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 ciproduces 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
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
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
- GitHub Docs: Concurrency
- GitHub Docs: Control the concurrency of workflows and jobs
- GitHub Docs: Dependency caching
- GitHub Docs: Dependency caching reference
- GitHub Docs: Workflow syntax for GitHub Actions
- GitHub Docs: Skipping workflow runs
- GitHub Docs: Managing GitHub Actions settings
- GitHub Changelog: Control GitHub Actions cache access with cache-mode
Connect with Me
If you found this article helpful, let's connect!
- π» GitHub: johnnylemonny
Top comments (2)
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! π
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!