Technical Debt Becomes a Business Decision When It Has a Payback Period

There is a piece of capacity planning advice that has become close to consensus, and it is good advice. Measure your unplanned work for a quarter. It will probably eat a quarter to a third of the team. Stop pretending it isn't there, reserve that share as slack, and plan the roadmap on what's left.

It works. The roadmap stops slipping every month, and the team stops apologising for incidents it could never have planned.

It also has a side effect nobody mentions. Once the slack is a line in the plan, the unplanned work has a home. It no longer competes with anything, it no longer surprises anyone, and it can stay exactly the size it is forever. Measured only by its size, the slack is a budget. And a budget, once approved, is the last thing anyone looks at.

The bucket isn't random

Look inside the unplanned work of any team that has been running a system for a few years and you will rarely find a random spread of bad luck. You find the same few return addresses.

An integration that nobody owns and that breaks every time the other side deploys. A batch job that needs a manual restart after every month end. A certificate that someone renews by hand. A shared reporting database that four teams read directly, so every schema change produces a week of "urgent" tickets. A dependency two major versions behind, which turns every security advisory into a small project.

Each of those was a decision once, usually a reasonable one: ship the integration now and harden it later, share the database because it was faster than building an API. The first post in this series argued that decisions you don't write down become debt. This is where the debt sends its bill. The interest isn't paid in a separate account. It is paid in hours, from the slack, by whoever happens to be on call.

Why debt loses every planning meeting

Technical debt has a reputation problem, and engineers mostly cause it themselves. We argue for paying it down in our own language. The code is messy. The design is wrong. It will hurt us later.

On the other side of the table, a feature arrives with a revenue estimate, a customer name, a date. When the two meet in a planning meeting, the feature wins, every time, and it should. One side has a number and the other has a feeling.

The fix is not to argue harder. Give the debt a number in the same currency the feature uses. You already have most of it, because the interest is being paid right now, in the unplanned work. Nobody is writing down where it goes.

Tag the source, not the category

The change is small. Every piece of unplanned work gets one extra field: the source it came from.

A few rules make the difference between a field that tells you something and a field that fills up with noise.

The source names something you could fix. "Incident", "bug" and "support" are categories. They tell you what kind of work it was, not where it came from. A source is a system, an integration, a job, a decision: partner-sync, manual-certs, shared-reporting-db. If you can't imagine a project that would make the source go away, it isn't a source yet.

Whoever did the work tags it. Not the owner of the system it came from, who has every reason to see the problem somewhere else.

"Untagged" is allowed, and it is counted. Forcing a guess produces fiction. Leaving it blank and counting the blanks tells you how much of your capacity goes somewhere you can't name, which is a finding in itself.

The list stays short. If a quarter produces sixty distinct sources, merge them. You want the ten or fifteen that matter, named consistently enough that the hours add up.

Most ticket systems can do this with a label or a custom field. It costs each person a few seconds per ticket. The data you get back is worth more than any debt inventory written in a workshop, because it is measured on the work that actually happened, and nobody had to remember anything.

Turn it into an interest statement

At the end of the quarter, export the tickets and add up the hours by source. Then, for the top few, estimate one more number: roughly how many hours it would cost to remove that source. Rewrite the integration, automate the renewal, put an API in front of the shared database.

Divide the fix cost by the hours it saves per quarter and you have a payback period.

This script does the arithmetic from a CSV export, with no dependencies:

// scripts/interest-statement.mjs
// Ranks the sources of unplanned work by the hours they cost, and the payback of fixing each.
// Usage: node scripts/interest-statement.mjs tickets.csv [fixes.csv] [quarters=1]
//   tickets.csv: an export with at least the columns hours, source, planned
//   fixes.csv:   source, fix_hours  (your estimate of what removing the source would cost)
import { readFileSync } from 'node:fs'

const [ticketsPath, fixesPath, quarters = '1'] = process.argv.slice(2)

// Minimal CSV reader: handles quoted fields and commas inside them, which ticket titles always have.
function readCsv(path) {
  const rows = []
  let row = [], field = '', quoted = false
  const text = readFileSync(path, 'utf8')
  for (let i = 0; i < text.length; i++) {
    const c = text[i]
    if (quoted) {
      if (c === '"' && text[i + 1] === '"') { field += '"'; i++ }
      else if (c === '"') quoted = false
      else field += c
    } else if (c === '"') quoted = true
    else if (c === ',') { row.push(field); field = '' }
    else if (c === '\n') { row.push(field); rows.push(row); row = []; field = '' }
    else if (c !== '\r') field += c
  }
  if (field || row.length) { row.push(field); rows.push(row) }
  const [header, ...data] = rows
  const keys = header.map(h => h.trim().toLowerCase())
  return data.map(r => Object.fromEntries(keys.map((k, i) => [k, (r[i] ?? '').trim()])))
}

const unplanned = readCsv(ticketsPath).filter(t => t.planned.toLowerCase() !== 'yes')
const fixes = new Map(
  (fixesPath ? readCsv(fixesPath) : []).map(f => [f.source, Number(f.fix_hours)])
)

const bySource = new Map()
let total = 0
for (const t of unplanned) {
  const source = t.source || 'untagged'
  const hours = Number(t.hours) || 0
  bySource.set(source, (bySource.get(source) ?? 0) + hours)
  total += hours
}

const rows = [...bySource]
  .map(([source, hours]) => {
    const perQuarter = hours / Number(quarters)
    const fix = fixes.get(source)
    return {
      source,
      'hours/quarter': Math.round(perQuarter),
      share: `${Math.round((hours / total) * 100)}%`,
      'fix (hours)': fix ?? '',
      'payback (quarters)': fix ? (fix / perQuarter).toFixed(1) : '',
    }
  })
  .sort((a, b) => b['hours/quarter'] - a['hours/quarter'])

console.table(rows)

The output is the page you bring to the planning meeting. With illustrative numbers, for a team of six over one quarter, it looks something like this:

Source Hours/quarter Share Fix (hours) Payback (quarters)
partner-sync 210 34% 320 1.5
shared-reporting-db 120 19% 480 4.0
untagged 95 15%
month-end-batch 70 11% 90 1.3
manual-certs 30 5% 40 1.3
everything else 100 16%

Read it the way finance would. Two sources take more than half of the slack. One of them costs 320 hours to remove and gives back 210 every quarter from then on: that fix pays for itself in a quarter and a half and keeps paying after that. The other is real but slower, and it might reasonably wait a year. The month-end batch is small, but almost free to fix.

None of this is an argument about code quality anymore. It is an investment with a cost, a return and a payback period, competing with features on the same terms. Sometimes it should lose. The difference is that now somebody decides.

Then the slack can shrink

This is the part the capacity advice was missing. Reserving slack makes the plan honest. Tracing the slack to its sources is what lets it get smaller.

When the partner sync is fixed, the next quarter's statement shows it. The 210 hours don't disappear into general busyness, they come back as capacity you can put on the roadmap, and you can prove where they came from. That is also the only argument for paying down debt that survives a change of manager: the hours were measured before and after, by the same people, the same way.

It also feeds back into the decision records from earlier in this series. A source whose hours climb quarter after quarter is an assumption failing in public. If the original decision had a reopen condition, this is the signal it was waiting for.

There is a ceiling

Hours undercount the interest. They don't capture the context switch, the roadmap item that slipped because the person who knew the partner sync was busy fixing it, or the risk of the vulnerability that hasn't been exploited yet. That makes the payback estimate conservative, which is the right direction for an estimate you are going to defend.

Some unplanned work has no source you can remove. A regulator changes a rule, a customer has a genuine emergency, the market moves. That work belongs in the slack, and the statement will show it as a long tail of small, unrepeated sources. That is fine. The point is to find the repeat offenders, not to drive the slack to zero.

The tags are fuzzy. Two people will tag the same incident differently, and some hours will be guessed. For ranking ten sources by cost that is good enough. Don't use it for anything that needs to be accurate to the hour, and don't use it to rank people.

And the fix estimates are estimates. They will be wrong in the usual direction. A source that pays back in a quarter and a half can survive a fix that costs twice the estimate. One that pays back in four quarters probably can't.

The rule I'd leave you with

Reserve the slack, because pretending unplanned work doesn't exist is how roadmaps fail. But don't let it become a budget nobody reads. Every hour in it came from somewhere, and most of them came from the same few places.

Technical debt stays a technical complaint for as long as it has no number. Give it a source, a cost per quarter and a payback period, and it becomes a business decision, which is the only kind that ever gets made.

So look at last quarter's unplanned work. Could you name the three sources that cost you the most? And if you could, what would it cost to make one of them go away?