Measure first: baselines, phases and the critical path
Build-time optimization means shortening the time between a commit and a usable artifact. It starts with measurement because assumptions about where time goes are often wrong, and without a baseline you cannot show that a change helped.
Record wall-clock time per stage, not only the total. A small shell wrapper is enough. It takes a timestamp before and after each stage command, for example with date +%s, and appends the stage name, duration, commit SHA and environment to a log file. Keep local and CI timings in separate series. CI runners differ from laptops in CPU, disk speed and cache state, so mixing the two hides real regressions and improvements.
Run each configuration several times and track the median and the variance instead of trusting one run. A single run can be skewed by a cold cache or a busy shared runner. High variance is also a finding: it often points to network-bound work such as dependency downloads.
Next, split the build into phases: dependency install, code generation, compilation, tests, packaging and image build. Then note which phases depend on which. The critical path is the longest chain of dependent phases, and its length sets the minimum wall-clock time.
As an illustrative estimate with assumed inputs, suppose tests (6 minutes) and the image build (2 minutes) both run in parallel after compilation. Cutting a minute from the image build saves nothing, because the image build is not on the critical path. Only shortening tests or an earlier phase reduces the total. After each change, re-measure, because a different chain may now be the critical path.
#!/usr/bin/env bash
# time-build.sh: run each build stage and append its wall-clock duration to a CSV log.
set -euo pipefail
LOG="${BUILD_TIMING_LOG:-build-timings.csv}"
# Label the series so local and CI timings stay separate. Most CI systems set CI.
ENV_NAME=$([ -n "${CI:-}" ] && echo ci || echo local)
SHA=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
[ -f "$LOG" ] || echo "timestamp,env,sha,stage,seconds,exit_code" > "$LOG"
stage() {
local name="$1"; shift
local start end status
start=$(date +%s) # one-second resolution is enough for multi-second stages
"$@" && status=0 || status=$?
end=$(date +%s)
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ),$ENV_NAME,$SHA,$name,$((end - start)),$status" >> "$LOG"
return "$status" # still fail the build when a stage fails
}
# One call per phase, so each phase gets its own row. Replace the commands with your own.
stage deps npm ci
stage codegen npm run codegen
stage compile npm run build
stage test npm test
stage package npm pack
stage image docker build -t example/app:dev .
# After several runs, get the median for one stage in one environment, for example:
# awk -F, '$2=="ci" && $4=="compile" {print $5}' build-timings.csv | sort -n |
# awk '{a[NR]=$1} END {print (NR % 2 ? a[(NR+1)/2] : (a[NR/2] + a[NR/2+1]) / 2)}'
A small Bash wrapper that records the duration and exit code of each build phase, tagged with commit SHA and a local or CI label, so you can compare medians per stage across runs.
How build caching works and what makes a task cacheable
A build cache treats each task as a function. Before running it, the build tool computes a cache key by hashing everything the task declares as input: the source files it reads, relevant configuration files, the versions of compilers and other tools it invokes, selected environment variables, and the hashes of upstream tasks it depends on. If that key matches a stored entry, the tool restores the recorded outputs, such as build artifacts and terminal logs, and skips execution. If any input changes, the key changes and the task runs.
Why determinism is the price of admission
This model is only safe when the same inputs always produce the same outputs. A task that embeds timestamps, reads the network, or depends on a running service breaks that contract. End-to-end tests against a live backend are the standard example. The result depends on the backend's state, which the hash cannot see. A cached "pass" from yesterday says nothing about today.
Hit rate beats cache speed
A fast cache that rarely hits saves almost nothing, because every miss pays the full task cost plus the lookup. Shaving a second off restore time matters far less than moving from 40% to 80% hits. Overly broad inputs drive hit rate down. Including READMEs or unrelated directories, for example, invalidates keys on changes that cannot affect output.
The dangerous failure: incorrect hits
The opposite mistake is worse. If a task reads an environment variable, a toolchain version, or a generated file that it never declares, changing that input leaves the key untouched. The cache then serves stale output, and nothing signals the error. Misses cost time. Incorrect hits ship wrong artifacts.
Implementing caching: explicit inputs, outputs and a shared remote cache
Incorrect hits cost trust, so the first step is to make the cache key's contents explicit instead of relying on defaults.
In Nx, caching is enabled per target through targetDefaults in nx.json. The same block accepts inputs and outputs, either globally or per project in project.json ([1]). Two declarations matter most. First, exclude *.md from build inputs. A README edit cannot change a compiled artifact, so it should not invalidate the cache. Second, declare the output directory, such as the project's folder under dist. Nx restores files only from declared outputs. If you omit them, a cache hit replays the terminal log but leaves no artifacts, and later steps fail or quietly rebuild.
Inputs can fail in two directions. If they are too broad, unrelated changes cause misses, which costs time but is safe. If they are too narrow, an undeclared dependency such as an environment variable, a codegen file, or a toolchain version produces wrong hits. When you are unsure, include the input. Remove it only after you have confirmed it does not affect output.
A local cache helps only the machine that filled it. A shared remote cache lets CI runs and developers reuse each other's results. Allow only trusted CI jobs on protected branches to write to it, and give everyone else read-only access. Otherwise a developer laptop or a fork's pull request can upload a poisoned artifact that every later build will trust.
Task caching does not cover dependency downloads or toolchain installs. Cache those separately in CI, keyed on a hash of the lockfile and the toolchain version, so the cache is rebuilt exactly when those inputs change.
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"],
"production": [
"default",
"!{projectRoot}/**/*.md",
"!{projectRoot}/**/*.spec.ts"
]
},
"targetDefaults": {
"build": {
"cache": true,
"dependsOn": ["^build"],
"inputs": [
"production",
"^production",
{ "env": "NODE_ENV" },
{ "runtime": "node --version" }
],
"outputs": ["{workspaceRoot}/dist/{projectRoot}"]
}
}
}
An nx.json that caches build targets. It excludes Markdown and spec files from build inputs, adds an env var and the Node version to the cache key, and declares dist output so cache hits restore artifacts as well as logs.
Faster container images: layer ordering and build context
Container builds have their own layer cache, and it follows a simple rule. Docker caches each instruction as a layer. When an instruction changes, or a file it copies changes, that layer and every layer after it are rebuilt. The order of the Dockerfile therefore decides how much work a one-line code change causes.
Order layers from least to most volatile
A common slow pattern copies the whole repository first and then runs the dependency install. Because the copied files include every source file, any edit invalidates the copy layer. The install runs again even when no dependency changed.
The fix is to copy only the dependency manifest and lockfile, run the install, and then copy the source. A source edit now invalidates only the final copy and the compile step. The install layer is reused until the lockfile changes, so it is rebuilt only when it needs to be. Put the base image and system packages above all of this, because they change least often.
Keep the build context small
The builder receives the build context, which is the directory you pass to docker build. Large or irrelevant files make the transfer slower. They also make a broad COPY . . more fragile, because a change to any file it copies, even one the build never uses, invalidates that layer.
Add a .dockerignore that excludes:
.git- local dependency directories such as
node_modules - build outputs such as
dist - logs, test coverage and editor files
.envfiles
Leaving out .env files also keeps secrets out of the image.
# BEFORE: any source edit invalidates COPY, so npm ci reruns every build
# FROM node:20-slim
# WORKDIR /app
# COPY . .
# RUN npm ci
# RUN npm run build
# CMD ["node", "dist/server.js"]
# AFTER: layers ordered from least to most volatile
FROM node:20-slim
# System packages change rarely, so they sit near the top
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy only the manifest and lockfile; this layer changes only when deps change
COPY package.json package-lock.json ./
RUN npm ci
# Source changes invalidate only the layers from here down
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]
# --- .dockerignore (separate file, next to the Dockerfile) ---
# .git
# node_modules
# dist
# coverage
# *.log
# .vscode
# .idea
# .env
# .env.*
A before/after Dockerfile that copies the dependency manifest and lockfile before the source so the install layer stays cached, followed by a sample .dockerignore that keeps the build context small and .env files out of the image.
Affected-only builds and sizing parallelism
A monorepo build tool that knows the dependency graph can compare the current commit with a base, usually the merge base with the main branch. It maps changed files to the projects that own them and walks the graph to find every dependent. Only those projects get built and tested. A change to a leaf service touches one node. A change to a shared library fans out to every consumer, which is correct because those consumers might now break.
The graph also shows which tasks are independent. Projects with no path between them can build at the same time, while dependents wait for their dependencies. Set concurrency from the runner, not from habit. Start at the CPU core count. Then check memory: divide available RAM by the peak memory of your heaviest task, such as a compiler or a test runner that forks workers, and use the smaller of the two numbers. As an illustrative estimate, a runner with 8 cores and 16 GB, running tasks that peak near 3 GB, supports about 5 parallel tasks, not 8. Going past that causes swapping or out-of-memory kills, which are slower than running fewer tasks.
Limits to plan for
- Shared resources. Parallel tests that use one database, fixed ports or the same temp paths will collide. Give each worker its own schema, use random ports, or run those suites one at a time.
- Order-dependent tests. Tests that rely on state left behind by earlier tests pass in sequence and fail randomly in parallel. Fix them; don't hide them with retries.
- Implicit dependencies. The graph only knows about imports it can see. Shared config files, code generators, schemas and runtime lookups by name are invisible to it. If you don't declare these links, affected-only runs skip code that actually changed. Declare them explicitly, and schedule a regular full build to catch any you missed.
#!/usr/bin/env bash
# Size CI concurrency from the runner itself, then find the base commit
# that a graph-aware build tool uses to compute affected projects.
set -euo pipefail
# Peak RSS of your heaviest task (compiler, or test runner with its workers).
# Assumed value: measure it on your own runner and update it.
PEAK_TASK_MB=3072
cores=$(nproc)
avail_mb=$(awk '/^MemAvailable:/ {print int($2 / 1024)}' /proc/meminfo)
mem_slots=$(( avail_mb / PEAK_TASK_MB ))
# Take the smaller of the CPU limit and the memory limit, and never go below 1.
jobs=$(( cores < mem_slots ? cores : mem_slots ))
if (( jobs < 1 )); then jobs=1; fi
echo "cores=$cores avail_mb=$avail_mb mem_slots=$mem_slots -> jobs=$jobs"
# Compare against the merge base with main, not main's tip, so commits
# that landed on main after this branch was created don't count as changes.
# A shallow clone may lack the history merge-base needs; fetch full history.
git fetch --no-tags origin main
base=$(git merge-base origin/main HEAD)
echo "Files changed since $base:"
git diff --name-only "$base" HEAD
# A scheduled full build catches what affected-only runs miss, such as
# shared config, codegen inputs and schemas the graph doesn't know about.
if [[ "${FULL_BUILD:-false}" == "true" ]]; then
echo "mode=full"
else
echo "mode=affected base=$base"
fi
# Pass these values to your build tool's affected command,
# which takes a base ref and a parallelism limit.
export AFFECTED_BASE="$base" BUILD_JOBS="$jobs"
A CI shell step that limits parallel tasks to whichever is smaller, the core count or available memory divided by the heaviest task's peak, computes the merge base used for affected-only builds, and allows a scheduled full build.
When to use these techniques, when to avoid them, and the alternatives
Who benefits most
Platform and backend teams running monorepos, or any CI pipeline where engineers routinely wait on builds, get the largest return. The value of caching grows with how much work repeats: many projects, many contributors and many CI runs over mostly unchanged code. If a typical change touches a small part of a large graph, affected-only builds and cache hits can skip most of the pipeline.
When to skip it
In a small repo whose builds already finish quickly, the setup effort may never pay off. You have to declare inputs and outputs, debug false cache hits and teach new engineers how the cache behaves. Remote caching also has a per-task cost: the runner hashes inputs, makes a network call and downloads the artifact. For small or fast tasks, such as a lint pass over a few files, that round trip can take longer than running the task again. Leave those tasks uncached.
Alternatives compared
| Approach | What it fixes | Main limitation |
|---|---|---|
| Bigger CI runners | Speeds up CPU-bound steps with no config changes | Cost rises linearly, and serial steps and the critical path stay the same |
| Splitting the repo | Shrinks each build | Moves the work into versioning and coordinating changes across repos |
| Plain CI dependency caching only | Skips package downloads | Still recompiles and retests everything |
| Build-tool-native incremental compilation | Reuses work within one tool on one machine | Usually local only and not shared across CI runs |
| Full caching plus graph tooling | Shares results across machines and skips unaffected work | Highest setup and maintenance cost, and depends on correct inputs |
Impact on cost and engineering time, and keeping gains from regressing
Estimating the payoff
Engineers lose time to builds when they wait on them. CI pays for every runner-minute. Estimate the two separately:
- Engineer hours saved per day = engineers × builds per engineer per day × minutes saved per build × fraction of build time spent waiting ÷ 60
- CI minutes saved per day = CI builds per day × minutes saved per build
Illustrative estimate (assumed numbers, not a benchmark): 20 engineers each trigger 6 builds a day. Each build gets 8 minutes faster, and engineers wait on about half of that time. That's 20 × 6 × 8 × 0.5 ÷ 60 = 8 engineer-hours a day. If those 120 builds also run in CI, that's 120 × 8 = 960 runner-minutes a day.
Subtract the ongoing costs. Someone has to tune inputs when a missing environment variable or an overly broad file glob causes stale hits or constant misses. Remote-cache storage and network transfer add cost, and large artifacts can take longer to download than to rebuild. The cache is also part of your supply chain: anyone who can write to it can put a poisoned artifact into other people's builds. Let only trusted CI jobs write to the cache, and give developer machines and fork builds read-only access.
Preventing regressions
Re-measure after every change against the baseline from section 1. Chart build duration and cache hit rate on a dashboard, so a slowdown is visible in the week it ships. Track p90 and p99 durations as well as the median. A new branch or an evicted cache still triggers a cold build that costs the full amount. If the median improves but the tail stays flat, part of the team is still waiting as long as before.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to reduce build time, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.