What engineering cost really is: rework, waiting, and coordination
Salaries and cloud bills are inputs. The cost you can actually reduce is engineer-hours that produce nothing shipped. These hours fall into three categories: rework, waiting, and coordination. They are a large share of spend on backend and platform teams that share CI pipelines, staging environments, and services, because a delay in one shared system stalls many people at once.
| Category | What it looks like | Where to get data |
|---|---|---|
| Rework | Reopened tickets, reverted merges, hotfixes | Issue tracker, VCS history |
| Waiting | CI queues, review latency, broken environments | CI logs, PR timestamps |
| Coordination | Cross-team handoffs, blocked dependencies | Ticket state changes, standup notes |
A simple cost model
Annual cost = lost hours per year × loaded hourly cost
For CI waits: engineers × waits per day × lost hours per wait × working days
For rework: engineers × productive hours per year × rework rate
Illustrative estimate (assumed numbers, not a benchmark):
- Team: 8 engineers
- Loaded cost: $100/hour
- Working days: 220 per year
- CI waits: 6 per day, each costing 0.2 hours of lost focus
- Rework: 15% of 1,500 productive hours per engineer per year
Results:
- CI waiting: 8 × 6 × 0.2 × 220 = 2,112 hours, or about $211,000
- Rework: 8 × 1,500 × 0.15 = 1,800 hours, or $180,000
These numbers show direction, not precision. "Lost focus per wait" is the input most likely to be wrong. Replace every assumption with your team's own CI and PR data before using the model to set priorities.
Why late defects cost more and where to spend testing and review effort
A defect's cost depends less on how hard it is to fix than on how far it has travelled. At design time, a wrong assumption costs a conversation. In review, the author still holds the context. After CI and staging, someone has to reload that context, often after switching tasks. In production, the fix also needs detection, triage, coordination across teams, customer communication, and sometimes data repair.
| Stage found | What drives the cost | Who is involved |
|---|---|---|
| Design | Rewriting a paragraph | Author, one or two reviewers |
| Code review | Small rework while context is fresh | Author, reviewer |
| CI | Context switch, rerun wait | Author |
| Staging | Reproduction, shared-environment contention | Author, QA, other teams blocked |
| Production | Incident handling, blast radius, data cleanup, follow-up | On-call, owners, support, users |
Use this curve to aim effort at defects that are expensive to discover late. These include schema changes, public API contracts, auth logic, and anything that writes persistent data. These deserve careful review and targeted tests. Internal refactors behind stable interfaces need less, because a mistake there stays contained and a revert is cheap.
When a design doc pays off
Write a short RFC when a change fixes something that is hard to undo: interfaces, data models, or contracts other teams will build against. One page that states the problem, the proposal, and the rejected alternatives is usually enough. Skip it for reversible changes such as feature-flagged behaviour or internal code paths. Planning applied everywhere adds review latency to every change, and that delay can cost more than the rework it was meant to prevent.
Cost of a fix rises with each stage the defect passes ----------------->
+--------+ +--------+ +--------+ +---------+ +------------+
| Design |-->| Review |-->| CI |-->| Staging |-->| Production |
+--------+ +--------+ +--------+ +---------+ +------------+
rewrite a fix while context repro, incident,
paragraph context is switch, shared env, blast radius,
fresh rerun wait teams blocked data cleanup
+-----------------------+
| Hard to undo once |
| shipped? |
+-----------------------+
yes | | no
+-----+ +-------+
| |
v v
+-----------------------------+ +-----------------------------+
| Schema, public API, auth, | | Internal refactor, flagged |
| persistent writes, cross- | | behaviour, stable interface |
| team contracts | | |
+-----------------------------+ +-----------------------------+
| |
v v
+-----------------------------+ +-----------------------------+
| 1-page RFC, careful review, | | No RFC, normal review, |
| targeted tests: catch it | | cheap revert if wrong; |
| early at Design or Review | | RFC would only add latency |
+-----------------------------+ +-----------------------------+
A defect costs more at each stage it passes, so put RFCs, careful review and targeted tests on changes that are hard to undo, and let reversible changes go through normal review.
How to cut CI wait time without letting regressions through
CI wait time is paid by every engineer on every push, so it compounds faster than most engineering costs. The expensive part is usually the engineer who switches context while waiting and loses focus, not the compute.
Illustrative estimate (assumed numbers, not a benchmark): weekly waiting cost = engineers × pipeline runs per engineer per day × blocked minutes per run × working days. With 20 engineers, 6 runs a day, 10 blocked minutes per run and 5 days, that is 20 × 6 × 10 × 5 = 6,000 minutes, or 100 engineer-hours per week.
Cache with keys that track every input
Key dependency caches on a hash of the lockfile plus the OS and toolchain version. A key that omits an input serves stale artifacts, so builds pass against dependencies you no longer ship. A key that includes something volatile, such as a timestamp, never hits.
Run affected tests, and run everything on a schedule
Selective test runs use the dependency graph to skip modules a change cannot affect. The graph is only as accurate as its declarations. An undeclared dependency, a shared fixture or a config file read at runtime can let a regression through without any test failing. A scheduled full run on the main branch, nightly or more often, catches what selection missed. When it fails, treat that as proof the graph is wrong and fix the graph.
Parallelize, and handle flaky tests on purpose
Shard suites by historical test duration rather than file count, so all shards finish at about the same time. Blind retries hide real race conditions and add run time. Move flaky tests into a non-blocking job, give each one an owner and a deadline, and then fix or delete it.
#!/usr/bin/env bash
# ci-tests.sh: compute a dependency cache key, then choose a full or selective test run.
# ./scripts/run-tests.sh is a placeholder for your test runner wrapper.
set -euo pipefail
# Cache key covers every input that changes the installed dependencies:
# OS, CPU architecture, toolchain version and the exact lockfile contents.
# It deliberately contains nothing volatile (no dates, no build numbers).
LOCK_HASH="$(sha256sum package-lock.json | cut -d' ' -f1)"
CACHE_KEY="deps-$(uname -s)-$(uname -m)-node$(node --version)-${LOCK_HASH}"
echo "dependency cache key: ${CACHE_KEY}"
# The scheduled main-branch run always executes the full suite.
# It is the check on the dependency graph used for selective runs.
if [[ "${CI_TRIGGER:-push}" == "schedule" ]]; then
echo "scheduled run: full suite"
exec ./scripts/run-tests.sh --all
fi
BASE_REF="${BASE_REF:-origin/main}"
CHANGED="$(git diff --name-only "${BASE_REF}...HEAD")"
# Some files are inputs the dependency graph cannot trace, such as the lockfile,
# shared fixtures and runtime config. A change to any of them forces a full run.
if printf '%s\n' "${CHANGED}" | grep -qE '^(package-lock\.json|test/fixtures/|config/)'; then
echo "shared input changed: full suite"
exec ./scripts/run-tests.sh --all
fi
# Otherwise run only the tests the graph links to the changed files.
printf '%s\n' "${CHANGED}" | ./scripts/run-tests.sh --affected-from-stdin
# Quarantined flaky tests still run, but a failure does not block the merge.
# Each quarantined test has an owner and a fix-or-delete deadline.
./scripts/run-tests.sh --quarantined || echo "WARNING: quarantined tests failed (non-blocking)"
A CI test script that builds a dependency cache key from the OS, architecture, toolchain version and lockfile hash, runs only the affected tests on pushes, runs the full suite on a schedule or when a shared input changes, and runs quarantined flaky tests without letting them block the merge.
Small pull requests and fast reviews: why latency costs more than effort
A review that takes twenty minutes of reviewer effort can cost a day or more of calendar time, and the calendar time is the expensive part. While a pull request waits, the author starts other work. When comments arrive, the author has to reload the original context, and the reviewer has to reload it again for the second pass. Meanwhile the main branch keeps moving. The branch goes stale, CI reruns against a new base, and merge conflicts show up in files the change never meant to touch. Every round trip pays these costs again.
Illustrative estimate (assumed numbers, not a benchmark): waiting cost per PR = round trips × (author reload + reviewer reload + rebase and CI fixes) Assume 3 round trips × (15 + 10 + 20 min) = 135 minutes of overhead. The review itself takes 20 minutes.
Practices that cut latency
- Size limits as guidance. A soft threshold of a few hundred changed lines should prompt a split. A hard gate gets bypassed with arbitrary cuts that are harder to review.
- Stacked changes. Build part two on top of part one, so the author can keep working while part one is in review.
- Review SLAs. Agree on a first response within half a working day. Track time to first review, not only time to merge.
- Feature flags. Incomplete work can merge disabled, so branches stay short-lived.
Some changes should not be split. A schema migration and the code that depends on it, a security-sensitive change, or an interface rename only make sense reviewed together. For these, split the change into clear commits inside one PR.
Flags also create cleanup debt. Each flag adds code paths and test combinations. Give every flag an owner and a removal date, and delete it once rollout is complete.
Platform engineering as cost optimization, and when it is not worth it
Repeated setup work is a quieter form of rework: each team rebuilds the same service skeleton, CI pipeline, logging config, and test environment. A paved road removes that repetition. It typically includes a service template that passes CI on day one, shared libraries for auth, config, and telemetry, and self-service environments that teams create without filing a ticket. The saving comes from work that is no longer repeated, so it scales with how many teams actually use it.
To estimate break-even, compare hours saved against hours spent:
Net hours per year = (teams × new setups per team per year × hours saved per setup) − (build hours ÷ amortization years) − maintenance hours per year
Illustrative estimate (assumed numbers, not a benchmark): Assume 10 teams, 4 new services per team per year, and 12 hours saved per setup. That saves 480 hours. Assume the platform costs 300 hours to build, amortized over 2 years (150 per year), plus 200 hours per year to maintain. The net is 480 − 150 − 200 = +130 hours per year. With 3 teams the savings drop to 144 hours, which is less than maintenance alone, so the platform never pays back.
That gap is why small organizations should hesitate. The second failure mode is a platform nobody adopts. Maintenance costs continue while savings stay at zero. Measure adoption before you add features.
| Option | Upfront cost | Ongoing cost | Fits when |
|---|---|---|---|
| Do nothing | None | Repeated setup per team | Few teams, rare new services |
| Documented conventions | Low | Docs drift without an owner | Teams agree, setup varies little |
| Shared scripts | Low–medium | Needs an owner | Repetitive steps, stable stack |
| Paved-road platform | High | Dedicated maintainers | Many teams, frequent setup, proven adoption |
Using production signals to find recurring rework and trimming non-production waste
Production data shows where rework repeats. Group incidents, rollbacks, and error-rate spikes by the component or change type that caused them, not by the person who shipped them. If one service accounts for a large share of rollbacks, or the same follow-up action ("add retry handling," "fix config drift") keeps appearing in postmortems, that is rework you pay for each time, with a known cause. Bring that pattern to prioritization: it argues for a fix over the next feature in a way that intuition cannot.
Expect pushback in budget discussions. The savings from a fix are delayed and counterfactual: you are claiming incidents that did not happen. Don't present them as guaranteed. Show the recurring cost directly, such as engineer-hours spent per incident, how often incidents recur, and on-call interruptions. Then commit to rechecking those same signals after the fix ships, so the claim can be verified.
Trimming non-production waste
Idle environments are the easier target:
- TTLs on preview environments, so a branch deployment is torn down after a set period without activity.
- Scale-to-zero schedules for staging and test clusters outside working hours.
- Ownership labels on every resource, so anything without a label is reviewed and then removed.
- Right-sizing test clusters to the load they actually receive, not to production capacity.
Before you cut anything, check which environments catch defects that would otherwise reach production, and protect those. Cutting too far brings back the old costs. Teams start queuing for a shared staging environment, and engineers fall back to local setups that drift from production, which leads to "works on my machine" bugs. Those failures are rework too, and they can cost more than the environments you shut down.
#!/usr/bin/env bash
# Run nightly (e.g. from cron or CI) to scale idle non-production namespaces to zero
# and list resources that have no owner.
set -euo pipefail
# Target staging and preview namespaces. Skip any namespace labeled keep-running=true,
# such as an integration environment that regularly catches defects before production.
SELECTOR='env in (staging,preview),keep-running!=true'
for ns in $(kubectl get namespaces -l "$SELECTOR" -o jsonpath='{.items[*].metadata.name}'); do
echo "Scaling down deployments in $ns"
for d in $(kubectl get deployments -n "$ns" -o jsonpath='{.items[*].metadata.name}'); do
replicas=$(kubectl get deployment "$d" -n "$ns" -o jsonpath='{.spec.replicas}')
# Save the current replica count so a morning job can restore it.
# Skip deployments already at zero so a second run does not overwrite the saved value.
if [ "${replicas:-0}" -gt 0 ]; then
kubectl annotate deployment "$d" -n "$ns" --overwrite \
"scale-down/original-replicas=$replicas"
fi
done
kubectl scale deployment --all --replicas=0 -n "$ns"
done
# List namespaces that have an env label but no owner label.
# A person reviews this list before anything is deleted.
echo "Non-production namespaces missing an owner label:"
kubectl get namespaces -l 'env,!owner' -o name
This nightly kubectl script scales labeled staging and preview namespaces to zero and saves their replica counts so they can be restored, skips environments marked as worth keeping, and lists namespaces with no owner label for review.
Measuring impact on cost and engineering time without gaming the metrics
Track four numbers. Each maps to one cost bucket from the rework-and-waiting model. Pair each with a counterweight so that gaming one shows up in another.
| Metric | Cost it reflects | How it gets gamed | Pair it with |
|---|---|---|---|
| Lead time for changes | Waiting in review and queues | Splitting work into trivial PRs | Change failure rate |
| Change failure rate | Rework after release | Not recording rollbacks or hotfixes as failures | Time to restore, incident count |
| Time to restore | Rework under pressure | Closing incidents before the fix ships | Reopened incidents |
| CI duration | Waiting on feedback | Skipping or quarantining slow tests | Defects found in production |
Baseline. Collect four to eight weeks of data before a change, and use medians so a single bad week doesn't distort the picture. Then compare the same length of window afterward. Review trends in a retrospective, not against targets. Once a number becomes a goal, teams optimize the number instead of the work.
Illustrative estimate (assumed numbers, not a benchmark):
Engineer-hours saved per week = (old CI minutes − new CI minutes) × runs per week × fraction of wait that blocks the engineer ÷ 60
With 14 → 8 minutes, 400 runs, and 0.5 blocked: 6 × 400 × 0.5 ÷ 60 = 20 engineer-hours per week. Multiply by your loaded hourly cost to get a money figure.
The usual alternatives are cutting headcount, tests, or review. They shrink the budget line immediately but move cost into rework:
- Fewer reviewers lengthen queues.
- Fewer tests push defects later, where they cost more to fix.
- Skipped reviews raise the change failure rate.
These costs appear in the same four metrics, usually a quarter later.