Guides / Build once, use everywhere

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

Jobs per run 4
Setup per job 3 min
Redundant minutes/run 9 min
Runs/day 40
Monthly cost (setup only) $65/mo

4 jobs × 3 min × 40 runs × 22 days × $0.006/min

After optimization (build-first)

Build jobs 1
Setup per build job 3 min
Downstream setup ~30 sec each
Runs/day 40
Monthly cost (setup only) $25/mo

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.

.github/workflows/ci.yml
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.

No caching - cold install every time
- uses: actions/setup-node@v4
  with:
    node-version: 20
- run: npm ci
# Downloads all deps from registry
# ~2 minutes every run
Cached - ~30 seconds on cache hit
- 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.

Language-specific caching examples
# 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.

.github/workflows/ci.yml
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.

.github/workflows/build-reusable.yml
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
.github/workflows/ci.yml (caller)
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.

Every job rebuilds from scratch
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

Guides / Build once, use everywhere

See which jobs duplicate build work

CostOps breaks down per-job setup time, install durations, and duplicate build steps across your workflows. See the waste before you refactor the YAML.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.