A GitHub Actions Pipeline, With the Seconds Attached
A workflow is triggers, a runner, an optional matrix, and steps. The one below is this site's own, and a real run took 84 seconds — npm ci 21s, lint 18s, build 29s, tests 3s, and 2 seconds for everything else. Caching is one line on setup-node rather than a separate step, the matrix runs in parallel so wall-clock is the slower job, and continue-on-error is how a check reports before it is ready to block.
Most answers to this query hand you a workflow with three steps and no numbers. The file is usually fine. What it cannot tell you is where the time goes, and that is the only thing that decides whether anyone waits for your pipeline or learns to ignore it.
So this article uses a pipeline that has been running on every push and pull request of this site for months, quotes it as it is, and puts the real per-step seconds beside it.
A workflow that actually runs
name: Node.js CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v7
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run build --if-present
- run: npm test
- name: Dependency audit (advisory)
if: matrix.node-version == '22.x'
run: npm audit --omit=dev --audit-level=high
continue-on-error: true
Four parts. Triggers — pushes to main and pull requests aimed at it. A runner — ubuntu-latest. A matrix — two Node versions. Steps — checkout, set up the runtime, install, and then the project's own commands.
That is a complete CI pipeline. Everything below is about what it costs and which lines are doing real work.
Where the 84 seconds go
Straight from the Actions API for a real successful run:
| Step | Time |
|---|---|
| Set up job | 1s |
actions/checkout@v7 |
1s |
| Use Node.js 22.x | 5s |
npm ci |
21s |
npm run lint |
18s |
npm run build |
29s |
npm test |
3s |
| Dependency audit | 2s |
Eighty-four seconds for the whole job. Install and build are sixty percent of it; checkout and job setup are two seconds together.
That table is the useful output of measuring a pipeline, because it tells you what optimising is worth. Shaving the checkout would be theatre. The install and the build are the only two places where an improvement is a real improvement.
Tip
gh api repos/OWNER/REPO/actions/runs/RUN_ID/jobsgives you this for your own pipeline. It is the fastest way to stop guessing about which step is slow — the web UI shows you totals, the API shows you the breakdown.
Caching is one line, not a step
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
That cache: 'npm' is the whole of it. No separate actions/cache block, no key, no restore-keys to get subtly wrong — setup-node handles it, keyed on the lockfile.
This repository currently holds 13 caches, and the most recent npm one is 201 MB.
Worth knowing what it does and does not skip. It caches the npm download cache, so npm ci does not fetch packages over the network — it still installs them, which is why that step is 21 seconds rather than 2. A cache is not a node_modules you get to keep.
The matrix, and what parallel means
Two entries, two jobs:
build (22.x) success 84s
build (20.x) success 85s
They run in parallel. So the matrix costs almost nothing in wall-clock — 85 seconds of waiting rather than 169 — and exactly double in runner minutes. That is the trade, and it is usually worth it.
What is not worth it is a matrix that answers no question. This one has a reason: the project's floor is Node 20.9 and the production server runs 22, so both ends of the supported range get built. A matrix of four versions nobody deploys is four times the minutes for no information.
Two lines that separate a toy from a pipeline
Lint as its own step. The work happens either way; making it a step changes what you see when the pipeline goes red. A named npm run lint failure in the job summary beats hunting for it inside a build that stopped early.
if: on a step. The audit runs on one matrix entry:
if: matrix.node-version == '22.x'
Its result does not depend on the Node version, so running it twice would be two identical answers and two minutes.
continue-on-error: true. This one is the most useful and the least used:
- name: Dependency audit (advisory)
run: npm audit --omit=dev --audit-level=high
continue-on-error: true
The step runs, its result is visible, and a finding does not fail the pipeline. That is how you add a check whose backlog is not clear yet — because the realistic alternative is not "add it and fix everything first", it is not adding it at all.
One redundancy, and why it might be deliberate
Look at the timings again: npm run build takes 29 seconds and npm test takes 3.
The reason is in package.json:
"prebuild": "npm run sql:wasm && npm test",
"build": "next build"
npm run build triggers prebuild, which runs the whole test suite. So by the time the workflow reaches its own npm test step, the tests have already run once inside the build — and the second run is three seconds because everything is warm.
Is that a bug? It is the same argument the workflow makes for lint. Three seconds buys a step called npm test that goes red on its own, instead of a test failure appearing as a failed build. Worth knowing it is happening; not obviously worth removing.
Common mistake
Discovering this kind of thing by reading a pipeline that has grown for a year and assuming every line was a decision. Some were; some are sediment. The timings tell you which ones cost anything.
What this pipeline does not do
It does not deploy. The CD half of "CI/CD" is a different decision with different risk, and a workflow that can push to production needs approvals, environments and secrets that a test workflow does not. Keeping them separate is a choice, not an omission — and where the artifact eventually runs, one host or a scheduler, changes what that second workflow looks like.
It has no service containers. Tests here need no database. When they do, service containers run one alongside the job — which is the CI equivalent of the local compose setup, and the point at which which database you chose starts mattering to your pipeline.
And it is a Node project, which only changes the middle. A JVM build swaps setup-node for setup-java and npm ci for a Maven or Gradle invocation; the triggers, the matrix, the caching input and the two lines above are identical. If you are setting that up, the JDK the pipeline installs is the same choice you made on your laptop.
Frequently asked questions
- How long should a CI pipeline take?
- Short enough that people wait for it. The one measured here is 84 seconds, of which npm ci is 21 and the build is 29 — so those two are the only places optimising is worth anything. Checkout and job setup together are 2 seconds; tuning them would be theatre.
- Do I need actions/cache for npm dependencies?
- No. actions/setup-node takes a cache input, so `cache: 'npm'` is the whole thing — one line instead of a separate step with a key and a restore-key you have to get right. This repository has 13 caches and the current npm one is 201 MB.
- What does a matrix actually cost?
- Wall-clock, almost nothing — the jobs run in parallel, and 84 and 85 seconds side by side is 85 seconds of waiting. Runner minutes, double. So pick matrix entries that answer a question: this project builds on Node 20 and 22 because 20.9 is its floor and the server runs 22.
- What is continue-on-error for?
- Adding a check that reports without blocking. The audit step here is advisory: it runs, its result is visible, and a finding does not fail the pipeline. That is how you introduce a check whose backlog is not clear yet, instead of the alternative everyone actually picks, which is not adding it.
- Why run lint as its own step instead of inside the build?
- So a lint failure is named in the job summary rather than buried in a build that never got to the interesting part. It costs nothing — the work happens either way — and it changes what you see when the pipeline goes red.