Modern CI/CD pipelines are supposed to accelerate delivery.
Yet many teams experience the opposite.
A one-line pull request triggers a workflow that spends more time preparing the environment than running the actual build or test suite. Engineers merge a trivial dependency bump, wait several minutes for CI to finish, and move on. Individually, the delay feels insignificant. Across hundreds of pull requests, it becomes a measurable drag on engineering throughput.
The frustrating part is that the slowdown often isn't caused by your code.
It's caused by the architecture underneath your CI runner.
The Hidden Waiting Tax
Consider a typical GitHub Actions workflow for a Node.js application.
A small pull request modifies a single API endpoint and triggers the following pipeline:
▶ Checkout repository 8s
▶ Setup Node.js 12s
▶ Restore package cache 34s
▶ pnpm install 51s
▶ Restore Docker cache 42s
▶ Docker build 118s
▶ Run tests 19s
▶ Upload artifacts 16s
Total runtime: 300s
Actual test execution: 19sThe tests themselves complete in under 20 seconds.
Everything else is environment reconstruction.
Why This Happens
Most CI systems rely on ephemeral runners.
Each workflow starts on a fresh virtual machine with an empty filesystem. No package cache. No Docker layers. No dependency tree. No build artifacts.
Every run must rebuild state from scratch.
The workflow becomes a cycle of:
- 1Download state
- 2Reconstruct state
- 3Execute workload
- 4Compress state
- 5Upload state
Repeat for every commit.
For organizations running hundreds or thousands of builds daily, this hidden waiting tax compounds into thousands of lost engineering hours annually.
Package Manager Cache Mechanics and the Compression Tax
Most engineers think of caching as a local disk operation.
In CI, it is usually a distributed storage operation.
When GitHub Actions restores a cache, it is not reading directly from local storage. Instead, it downloads a compressed archive from remote object storage, extracts it, and recreates the original directory structure.
The workflow looks roughly like this:
node_modules/
|
▼
tar.gz archive
|
▼
Object Storage
|
▼
Download
|
▼
Extract
|
▼
Restored CacheA typical setup looks like:
name: CI
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- run: pnpm testThe configuration looks simple.
The underlying operations are not.
A large dependency cache frequently generates logs resembling:
Run actions/cache@v4
Cache Size: ~2.1 GB
Downloading cache archive...
315 MiB / 2.1 GiB
812 MiB / 2.1 GiB
1.4 GiB / 2.1 GiB
2.1 GiB / 2.1 GiB
Average Speed: 145 MiB/s
Extracting cache archive...
tar -xzf cache.tgz
real 0m43.2s
user 0m36.4s
sys 0m11.8sThe workflow spends nearly a minute doing nothing except reconstructing files that already existed during the previous run.
This phenomenon is often called the compression tax.
The cache hit exists.
But the cost of accessing the cache remains high because the runner cannot access it locally.
Teams trying to optimize GitHub Actions cache performance often discover that cache transfer time eventually dominates pipeline duration.
The cache system works exactly as designed.
The architecture underneath it becomes the bottleneck.
Why Docker Builds Feel So Slow in CI
Docker introduces a second bottleneck.
Locally, Docker builds are fast because layer caches live permanently on disk.
When BuildKit encounters an unchanged layer:
Layer already exists
→ Reuse layer
→ Continue buildNo network operations occur. No downloads occur. No decompression occurs.
CI runners behave differently.
Since runners disappear after execution, Docker layers disappear too.
To compensate, teams use registry-based cache storage.
A common Buildx configuration looks like:
- name: Build Docker Image
uses: docker/build-push-action@v5
with:
context: .
push: false
cache-from: type=registry,ref=myapp:buildcache
cache-to: type=registry,ref=myapp:buildcache,mode=maxThis improves cache hit rates but introduces a new dependency: the network.
The build now follows:
Runner
|
▼
Registry
|
▼
Download Layers
|
▼
Extract Layers
|
▼
Resume BuildTypical logs reveal the hidden overhead:
#4 importing cache manifest
#4 resolve registry cache
#4 DONE 3.1s
#5 downloading cache layers
sha256:ab91...
Downloading 182 MB
sha256:f872...
Downloading 96 MB
sha256:dd12...
Downloading 241 MB
#5 DONE 31.4s
#6 extracting layers
Extracting layer 1/6
Extracting layer 2/6
Extracting layer 3/6
#6 DONE 17.6sThe cache hit technically succeeded.
Yet nearly 50 seconds were spent simply retrieving and extracting cached data.
This is why docker build caching in CI often feels much slower than developers expect after experiencing local Docker builds.
Local cache hits are disk reads. CI cache hits are distributed storage operations.
Shared Compute Makes Everything Worse
Caching is only part of the problem.
Shared compute introduces additional variance.
Most hosted CI platforms schedule workloads across large pools of shared virtual machines.
- CPU allocation may vary
- Disk performance may vary
- Network throughput may vary
Two identical workflows can produce dramatically different runtimes depending on neighboring workloads.
Engineers frequently encounter situations like:
Build #821 → 7m 12s
Build #822 → 4m 03s
Build #823 → 6m 48sNo code changed. The infrastructure conditions changed.
This unpredictability becomes particularly noticeable during:
- Large dependency installations
- Multi-stage Docker builds
- Monorepo workflows
- Integration test suites
- Artifact-heavy pipelines
The result is slower feedback loops and reduced developer confidence in build time estimates.
The Economics of Network-Backed State
Traditional CI architectures effectively turn local disk operations into network operations.
Every cache interaction becomes:
Disk Write
↓
Compression
↓
Upload
↓
Storage
↓
Download
↓
Extraction
↓
Disk ReadThe workflow repeatedly pays for:
- CPU cycles spent compressing data
- Network bandwidth
- Object storage transactions
- Archive extraction
- Workflow runtime
At small scale this overhead is tolerable. At organizational scale it becomes expensive.
A team running:
- 500 builds/day
- 4 minutes of cache overhead/build
Consumes:
500 × 4 minutes = 2,000 minutes/day
= 33 hours/day
= 1,000 hours/monthThose hours represent infrastructure spend and delayed developer feedback.
This is one reason many organizations begin searching for a faster GitHub Actions runner or a GitHub Actions runner replacement as repositories grow.
The Real Bottleneck Lives Below the Build System
Package managers are not the bottleneck.
Dockerfiles are not the bottleneck.
BuildKit is not the bottleneck.
The root issue is an infrastructure model that treats storage as disposable.
When runner state disappears after every workflow:
- Dependency caches must be downloaded
- Docker layers must be restored
- Artifacts must be reconstructed
- Entire environments must be recreated
The result is a system optimized around moving data rather than executing code.
The fastest cache is not a faster tarball.
The Final Problem Solver
The teams looking to reduce such build time issues can switch to Monk CI. Monk CI approaches the problem differently by providing a GitHub Actions runner replacement with persistent SSD-backed infrastructure, faster Docker layer caching, and significantly accelerated Actions cache performance. While GitHub-hosted runners frequently spend time reconstructing build state from remote storage, Monk CI focuses on reducing cache and Docker build overhead at the runner layer, allowing teams to achieve substantially faster workflow execution with minimal workflow changes.
Written by
shankar narayanan