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.