Too much work per run
Too many small jobs, too much overhead
By Keith Mazanec, Founder, CostOps ยท Updated January 30, 2026
A team splits their CI into 8 separate jobs: lint, typecheck, format, unit tests, integration tests, build, security audit, license check. Each job checks out the repo and installs dependencies from scratch. The actual checks take seconds. The setup takes a minute. And GitHub bills every job rounded up to the nearest minute, which means 8 jobs that each run for 40 seconds cost 8 full minutes. Consolidating those into 2–3 jobs cuts the bill in half without losing any signal.
Symptoms
How to tell if job overhead is inflating your bill
Open your repository's Actions tab and click into a recent workflow run. Look at the job list and their durations:
-
Setup dominates runtime. Expand any job and compare the time spent on actions/checkout, actions/setup-node, and npm ci versus the actual check. If setup is 45 seconds and the lint step is 8 seconds, 85% of that job's cost is overhead.
-
Many jobs under 1 minute. GitHub rounds each job up to the next whole minute. A job that finishes in 12 seconds is billed as 1 minute. Ten such jobs cost 10 billed minutes for under 2 minutes of actual compute. This is the single biggest source of billing waste from job sprawl.
-
Identical setup steps across jobs. Every job in the workflow runs the same checkout → setup-node → npm ci sequence. If you have 8 jobs, you're running npm ci 8 times per workflow invocation, downloading and extracting the same packages each time. A working dependency cache can reduce this cost but doesn't eliminate it.
-
High job count relative to workflow duration. A workflow that takes 3 minutes wall-clock but has 10 jobs is billing 10+ minutes. The parallelism feels fast for developers but costs 3–4× more than a sequential approach would.
Metrics
The per-minute rounding tax
GitHub bills each job's runtime rounded up to the nearest minute. This rounding is per-job, not per-workflow. The more jobs you have, the more rounding waste you accumulate. Here's a typical scenario for a Node.js project on Linux runners:
Before: 8 granular jobs
8 jobs × 1 min × 30 runs × 22 days × $0.006/min
After: 3 consolidated jobs
Save $8/mo · $100/year · per workflow
That's a single workflow on Linux at $0.006/min. On macOS runners at $0.062/min, the same 8-job workflow costs $328/mo and drops to $246/mo after consolidation, saving $82/mo. The rounding penalty hits harder the more jobs you have and the more expensive the runner.
Fix 1
Group fast checks into a single job
Lint, typecheck, format check, and security audit are all fast, read-only operations that need the same setup: checkout the code and install dependencies. There is no technical reason for these to be separate jobs. Running them as sequential steps in one job means one checkout, one install, one billed unit.
The tradeoff is that a failure in an earlier step prevents later steps from running. If the lint check fails, you won't see typecheck results until lint is fixed. In practice, this is rarely a problem because developers fix the first error and push again. If you want all checks to report independently, use continue-on-error: true on individual steps and check the outcomes at the end.
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npm run lint typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npx tsc --noEmit format: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npm run format:check audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci - run: npm audit --audit-level=high
jobs: quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: cache: npm - run: npm ci - run: npm run lint - run: npx tsc --noEmit - run: npm run format:check - run: npm audit --audit-level=high
Four jobs at under a minute each bill as 4 minutes. One job running all four checks sequentially finishes in about 1:30 and bills as 2 minutes. Same checks, half the cost, and only one dependency install instead of four.
Fix 2
Build once, share with downstream jobs
When you do need multiple jobs, such as running unit tests and integration tests in parallel, the worst pattern is having each job independently install dependencies and build the project. A better approach is a single build job that prepares the workspace, then passes the result to downstream jobs via artifacts. Our build once, use everywhere guide covers this pattern in depth.
The actions/upload-artifact and actions/download-artifact actions transfer files between jobs. Downloading an artifact is significantly faster than running npm ci from scratch, typically taking 5–15 seconds versus 30–120 seconds.
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: cache: npm - run: npm ci - run: npm run build - uses: actions/upload-artifact@v4 with: name: build-output path: | node_modules dist unit-tests: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: build-output - run: npm test integration-tests: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 with: name: build-output - run: npm run test:integration
The downstream jobs skip npm ci and npm run build entirely. They download the pre-built artifact in seconds and go straight to running tests. This eliminates the most expensive repeated step.
One caveat: artifact upload and download are not free in terms of time. Large node_modules directories (hundreds of MB) can take 20–30 seconds to upload and download. For small projects where npm ci takes 15 seconds, artifact sharing may not save anything. Measure both approaches, since this fix pays off most when the install or build step is expensive.
Fix 3
Use continue-on-error for independent checks in one job
The main objection to consolidating jobs is losing independent failure reporting. If lint fails, you want to still see whether typecheck and format checks pass. With separate jobs, each reports its own status. With a single job, the first failure stops everything by default.
The fix is continue-on-error: true on each check step, combined with a final step that evaluates all outcomes. Every check runs regardless of earlier failures, and the job still fails if any check failed.
jobs: quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: cache: npm - run: npm ci - name: Lint id: lint continue-on-error: true run: npm run lint - name: Typecheck id: typecheck continue-on-error: true run: npx tsc --noEmit - name: Format id: format continue-on-error: true run: npm run format:check - name: Check results if: always() run: | echo "Lint: ${{ steps.lint.outcome }}" echo "Typecheck: ${{ steps.typecheck.outcome }}" echo "Format: ${{ steps.format.outcome }}" if [[ "${{ steps.lint.outcome }}" == "failure" || "${{ steps.typecheck.outcome }}" == "failure" || "${{ steps.format.outcome }}" == "failure" ]]; then exit 1 fi
This gives you the best of both worlds: a single billed job with one setup phase, but full visibility into which checks passed and which failed. The job summary in the Actions UI shows each step's status individually.
Reference
When separate jobs are worth the overhead
Consolidation is not always the right call. Keep jobs separate when the overhead pays for itself:
| Keep separate when… | Why |
|---|---|
| Jobs need different runners | A Linux lint job and a macOS build job can't share a runner. Separate jobs are the only option. |
| Each job runs 5+ minutes | The per-minute rounding penalty is small relative to runtime. Parallelism saves wall-clock time with minimal billing waste. |
| Jobs need different services | Unit tests don't need PostgreSQL. Integration tests do. Running them separately avoids paying for a database container during the unit test phase. |
| Job is a required status check | Branch protection rules reference job names. If "lint" is a required check, it needs to be a separate job (or you need to update your branch protection config). |
The rule of thumb: consolidate checks that are fast, share the same setup, and run on the same runner type. Keep jobs separate when they're long-running, need different environments, or serve as distinct required status checks.
Reference
Overhead per GitHub Actions job
Every job on a GitHub-hosted runner pays a fixed cost before your code runs. Here's what that overhead looks like for a typical Node.js project:
| Phase | Typical | Notes |
|---|---|---|
| Runner provisioning | 5–20s | Queue + VM startup |
| actions/checkout | 2–5s | Depends on repo size |
| actions/setup-node | 1–3s | Cached on runner |
| npm ci (no cache) | 30–120s | Downloads from registry |
| npm ci (with cache) | 10–30s | Restores ~/.npm cache |
| Total overhead/job | 18–158s | Before your code runs |
If your actual check takes 8 seconds and overhead is 45 seconds, you're paying for 53 seconds of work, all billed as 1 minute. Multiply that by 8 jobs and you're billing 8 minutes for roughly 1 minute of useful compute. That's the job sprawl tax.
Related guides
Build Once, Use Everywhere
Stop rebuilding the same code in every job. Use artifacts to compile once and share downstream.
Matrix Explosion
When matrix strategies multiply job count combinatorially and each job repeats expensive setup.
Optimize Lint and Typecheck ROI
Evaluate whether lint and typecheck jobs earn their cost as separate CI steps.
Reduce CI Setup and Install Overhead
Minimize the fixed cost of checkout, install, and setup that every job pays before real work begins.