Too much work per run
Build once, use everywhere
By Keith Mazanec, Founder, CostOps ยท Updated January 30, 2026
A workflow has four jobs: lint, test, build, and deploy. Each one checks out the code, runs npm ci, and compiles TypeScript from scratch. That's the same 3 minutes of dependency installation and compilation repeated four times, totaling 12 minutes of redundant work per workflow run. You're billed for all of it. This is one of the most common sources of wasted CI minutes, and the fix is straightforward: build once, then share the output with every downstream job. If you also have too many small jobs, consolidating them alongside this pattern compounds the savings.
Symptoms
How to tell if your CI is rebuilding the same thing
Open a workflow run in the Actions tab and expand the step timings across jobs. If you see the same pattern, you're paying a multiplier:
-
Identical install steps across jobs. Every job runs npm ci, pip install, or go build independently. Each GitHub Actions job runs on a fresh VM with no shared state, which means every job starts from zero unless you explicitly share output between them.
-
Compilation repeated in test and deploy jobs. Your test job compiles the project to run tests. Your deploy job compiles the exact same code again to produce the deployment artifact. The output is byte-for-byte identical, but you're paying for both.
-
Setup time dominates actual work. A lint job that takes 15 seconds of actual linting spends 2 minutes on checkout, install, and compilation first. The useful work is 10% of the billed time.
Metrics
Quantify the duplicate build cost
Every duplicated install or compile step is a direct multiplier on your bill. Here's what it looks like for a Node.js project with 4 jobs, each running npm ci (2 min) and tsc (1 min) before doing any real work:
Before optimization
4 jobs × 3 min × 40 runs × 22 days × $0.006/min
After optimization (build-first)
Save $40/mo · $480/year · per workflow
That's on Linux at $0.006/min. On macOS runners at $0.062/min, the same 4-job workflow wastes $670/mo on redundant setup. Cut it to a single build job and you save $413/mo from a single workflow. Compiled languages like Rust and Go see even larger gaps because cold compilation can take 5–15 minutes per job.
Fix 1
Use a dedicated build job and share artifacts
The most impactful fix is restructuring your workflow so that a single build job handles dependency installation and compilation, then uploads the result as an artifact. Downstream jobs like test, lint, and deploy then download the artifact instead of rebuilding from scratch.
GitHub Actions artifacts are files persisted after a job completes and available to any other job in the same workflow run via the needs keyword. Use actions/upload-artifact@v4 to upload and actions/download-artifact@v4 to download in dependent jobs.
name: CI on: push: branches: [main] pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - run: npm ci - run: npm run build # Upload the compiled output for downstream jobs - uses: actions/upload-artifact@v4 with: name: build-output path: dist/ retention-days: 1 test: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: build-output path: dist/ - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - run: npm ci - run: npm test deploy: needs: [build, test] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - uses: actions/download-artifact@v4 with: name: build-output path: dist/ - run: ./deploy.sh dist/
The build job compiles once. The test and deploy jobs download the pre-built artifact instead of recompiling. The deploy job doesn't even need checkout or npm ci because it just deploys the compiled output.
One caveat: artifact upload and download add time. For small artifacts (under 50 MB), this is typically 5–15 seconds each way. For large artifacts, consider compressing with tar before upload. Set retention-days: 1 for CI artifacts since you won't need them after the workflow completes.
Fix 2
Cache dependencies across jobs and runs
Even with a build-first workflow, jobs that need node_modules for testing still run npm ci. Without caching, this downloads every dependency from the registry on every run. If your dependency cache isn't working, this step alone can dominate your bill. The setup-node action's built-in cache option eliminates most of that cost by restoring the npm cache from a prior run.
This works across all jobs and future workflow runs. The cache is keyed on package-lock.json by default, so it only invalidates when dependencies actually change.
- uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci # Downloads all deps from registry # ~2 minutes every run
- uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - run: npm ci # Restores ~/.npm cache first # ~30 seconds on cache hit
The same pattern works for other languages. Use setup-python with cache: 'pip', setup-go with cache: true, or Swatinem/rust-cache@v2 for Rust projects. Each one caches the language-specific dependency directory and restores it on subsequent runs.
# Python - uses: actions/setup-python@v5 with: python-version: '3.12' cache: 'pip' # Go - uses: actions/setup-go@v5 with: go-version: '1.22' cache: true # Rust (community action) - uses: Swatinem/rust-cache@v2 # Caches target/, registry, git checkouts
One caveat: built-in caching restores the package manager cache, not node_modules itself. The npm ci command still runs and links packages, but skips the network download. For a typical project, this cuts install time from ~2 minutes to ~30 seconds. If you need to skip npm ci entirely, cache node_modules directly with actions/cache@v4 keyed on your lockfile hash.
Fix 3
Skip install entirely with a direct dependency cache
The built-in cache option on setup-node caches the npm registry cache, but npm ci still runs to extract and link packages. If your lockfile hasn't changed, you can skip the install step entirely by caching node_modules directly using actions/cache@v4.
steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - name: Cache node_modules id: cache-deps uses: actions/cache@v4 with: path: node_modules key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - name: Install dependencies if: steps.cache-deps.outputs.cache-hit != 'true' run: npm ci - run: npm test
When the cache hits, npm ci doesn't run at all. The node_modules directory is restored directly from cache in ~5 seconds. The cache key uses hashFiles('**/package-lock.json') so it invalidates whenever dependencies change.
One caveat: caching node_modules directly means you skip npm ci's integrity checks. If you use post-install scripts that depend on the OS or Node version, include ${{ runner.os }} and the Node version in your cache key to avoid stale binaries. For most projects this works well, but test it before relying on it.
Fix 4
Extract build logic into reusable workflows
If your organization has multiple repositories with similar build steps, duplicate YAML becomes a maintenance problem on top of a cost problem. GitHub's reusable workflows let you define the build logic once and call it from any repository. This ensures every repo uses the optimized build-first pattern without copy-pasting YAML.
name: Build on: workflow_call: inputs: node-version: type: string default: '20' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} cache: 'npm' - run: npm ci - run: npm run build - uses: actions/upload-artifact@v4 with: name: build-output path: dist/ retention-days: 1
name: CI on: push: branches: [main] pull_request: jobs: build: uses: ./.github/workflows/build-reusable.yml with: node-version: '20' test: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: build-output path: dist/ - run: npm test
The workflow_call trigger makes a workflow callable from other workflows. This centralizes the build logic so you can update it in one place and every calling workflow picks up the change. For cross-repository reuse, reference the workflow by its full path: org/repo/.github/workflows/build.yml@main.
Reference
The anti-pattern: what duplicate builds look like
This is the workflow shape that costs you money. Every job independently checks out, installs, and compiles. The npm ci and npm run build steps are highlighted in red because each one is redundant work.
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci # 2 min - run: npm run build # 1 min - run: npm run lint # 15 sec test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci # 2 min (duplicate) - run: npm run build # 1 min (duplicate) - run: npm test # 3 min e2e: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci # 2 min (duplicate) - run: npm run build # 1 min (duplicate) - run: npm run e2e # 5 min deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci # 2 min (duplicate) - run: npm run build # 1 min (duplicate) - run: ./deploy.sh dist/
That's 4 × 3 min = 12 minutes of install + build. Only 3 minutes of that is unique work, and the other 9 minutes are exact duplicates. Apply Fix 1 (build-first with artifacts) and Fix 2 (dependency caching) together, and those 9 minutes drop to under 1 minute of artifact download time.
Reference
Typical build times by language
The cost of duplicate builds scales with compilation time. Here are typical cold build times on a standard 2-core Linux runner, and every duplicate job pays this cost again:
| Language | Cold install | Cold compile | Cached |
|---|---|---|---|
| Node.js / npm ci | ~2 min | 15–90 sec | ~30 sec |
| Python / pip | 1–3 min | N/A | ~20 sec |
| Go | 30–60 sec | 1–5 min | ~30 sec |
| Rust | 1–2 min | 5–20 min | 1–3 min |
For Rust projects, the difference is dramatic: a 4-job workflow with 10-minute cold compiles wastes 30 minutes per run on duplicate builds. At $0.006/min over 40 runs/day, that's $158/mo in redundant compilation. With Swatinem/rust-cache and a build-first pattern, most of that disappears.
Related guides
Too Many Small Jobs
When dozens of tiny jobs each repeat checkout and install, per-minute rounding compounds the waste.
Speed Up Builds
Cache build output across runs and enable framework-level incremental compilation.
Dependency Cache Not Working
Debug and fix GitHub Actions cache misses that force cold installs on every run.
Docker Builds Never Cached
Enable BuildKit layer caching so Docker builds skip work they've already done.