An estimate is a distribution, and you reported the mode
Two weeks means the most likely case. What anyone planning around you needs is the spread.
TL;DR
When you say "about two weeks", you are reporting the mode — the single most likely outcome. The person planning around you hears a commitment, and plans as though two weeks were the maximum.
Software estimates are right-skewed: there is a floor on how fast something can go and no ceiling on how slow. So the mode is systematically optimistic as a planning number, and reporting it alone is why "we were 80% done for three weeks" is a universal experience.
Report a range with its drivers. "Two weeks if the API is as documented, five if we have to reverse-engineer it — I will know which by Wednesday" is more useful than any single number, and it is also more honest.
The case this post is about
A payments-provider integration, estimated in a planning meeting:
"The Stripe integration? Two weeks."
Based on a real assessment: the API was documented, we had done one before, the scope was three endpoints and a webhook. Two weeks was a genuinely good guess at the most likely duration.
The launch date was set from it, marketing was briefed, and a dependent team scheduled their work to start in week three.
The symptom
It took five weeks. Not because of anything unforeseeable — because of four things that each might have happened and three of which did:
week 1 endpoints working in sandbox on plan
week 2 webhook signature verification: docs described v1,
the account was provisioned for v2 +4 days
week 3 test-mode/live-mode key handling differed from docs +3 days
week 4 compliance review required (nobody had asked) +5 days
week 5 shipped
The dependent team idled for two weeks. They had planned to start in week three on the assumption that two weeks was firm.
And the status updates were useless, in a specific way:
end of week 2: "about 80% done"
end of week 3: "about 80% done"
end of week 4: "about 90% done"
Each one was honest. Percentage-complete against a wrong denominator cannot help anyone.
Why the obvious fix failed
"Add a buffer — say three weeks." We did this on the next project. It produced a number with no stated reasoning, so when the work ran to four weeks nobody could tell whether the estimate or the execution was at fault. An unexplained buffer is an unfalsifiable estimate.
"Multiply by two." Same problem, plus it is visibly arbitrary and erodes trust in every future number. And it is wrong in the other direction often enough to be noticed: work that genuinely takes a week now gets two weeks of calendar.
"Break it into smaller tasks and sum them." Genuinely helps with the mode, and summing modes understates the total, because the independent small uncertainties compound. Our task breakdown summed to nine days and the work took twenty-five.
"Use story points so it isn't about time." Points were converted to time by someone else within a day, which is what happens, and the conversion discarded whatever uncertainty the points were meant to carry.
The actual mechanism
Three things.
Software durations are right-skewed. There is a hard floor — the work cannot take less than the time to write and verify it — and no ceiling. Unknowns only ever add. So the distribution has a long right tail, and three summary statistics diverge: the mode (most likely) is below the median, which is below the mean.
"About two weeks" is the mode. It is the answer to "how long if it goes the way I expect", which is the question your brain actually answers when you picture the work.
A planner needs a number they can commit to, which is a high percentile — more like the 80th or 90th. Handing over the mode and letting someone else treat it as a commitment is a silent substitution of one statistic for another.
And the mechanism behind the skew is specific, not mysterious: each unknown has a probability and a cost. The webhook version question had maybe a 40% chance of costing four days. Compliance review had maybe 30% of five days. None individually changes the mode — the single most likely path is still the one where each goes well — and together they make the expected duration much larger than the mode.

The mode answers "how long if it goes well". A plan needs "how long should I reserve". Those are different numbers, and reporting only the first makes you the source of someone else's missed commitment.
The fix
Report a range, name its drivers, and say when the range will narrow.
The estimate, as given on the next integration:
"Two weeks if the webhook API matches the docs and no compliance review is needed. Five if either goes the other way. The two things that decide it: whether our account is on webhook v2 — I can check that Monday — and whether payments compliance needs to review a new provider, which I have asked and expect an answer on by Wednesday. So I will have a firm number by Wednesday; plan for four weeks until then."
Four properties:
The range has named drivers, so it is falsifiable. Anyone can check whether the stated conditions held, which makes the next estimate more credible rather than more padded.
The optimistic case is stated as a condition, not a promise. "Two weeks if X" cannot be quoted as "two weeks" without the condition travelling with it.
There is a date when the uncertainty resolves. This is the most useful sentence. It converts "somewhere between two and five weeks" into "a decision point on Wednesday", which a planner can actually work with.
And a planning number is offered — four weeks — so nobody has to pick a percentile on your behalf.
Make the unknowns explicit in the ticket:
## Estimate
- Likely: 10 working days
- Plan against: 20 working days
- Drivers of the spread:
- [ ] webhook API version on our account (v1 vs v2) — ±4d — resolve by Mon
- [ ] compliance review required for a new provider — ±5d — asked, ETA Wed
- [ ] test/live key handling matches docs — ±3d — verify in sandbox, Tue
- Narrowed estimate due: Wednesday
Then replace percentage-complete with the unknown list. This is what fixes the useless status updates:
end of week 1
✓ webhook version: v2 confirmed (+4d, now in the plan)
✓ test/live keys: as documented (no impact)
? compliance: still waiting (±5d outstanding)
→ current estimate: 14 days, one unknown left
Status as "which unknowns have resolved" is honest and monotonic. Status as a percentage is neither.
And de-risk by resolving unknowns first, which is the behavioural change that matters most: spend day one checking the webhook version rather than building the endpoints you already know how to build. The unknown-first ordering costs nothing and collapses the range days earlier.
The arithmetic, since "the mode is not the mean" is the kind of claim that stays abstract until you watch it happen on your own numbers. With a handful of unknowns you do not need simulation — enumerate every combination, weight each by its probability, and read the statistics off directly:
data class Unknown(val name: String, val probability: Double, val extraDays: Int)
data class Outcome(val days: Int, val probability: Double)
fun outcomes(baseDays: Int, unknowns: List<Unknown>): List<Outcome> {
// Every subset of unknowns that could land. 2^n rows — exact, not sampled.
var rows = listOf(Outcome(baseDays, 1.0))
for (u in unknowns) {
rows = rows.flatMap { row ->
listOf(
Outcome(row.days + u.extraDays, row.probability * u.probability),
Outcome(row.days, row.probability * (1 - u.probability)),
)
}
}
return rows.groupBy { it.days } // collapse ties before reading stats
.map { (days, rs) -> Outcome(days, rs.sumOf { it.probability }) }
.sortedBy { it.days }
}
fun percentile(rows: List<Outcome>, p: Double): Int {
var cumulative = 0.0
for (row in rows) { // rows are sorted by duration
cumulative += row.probability
if (cumulative >= p) return row.days
}
return rows.last().days
}
fun report(baseDays: Int, unknowns: List<Unknown>): String {
val rows = outcomes(baseDays, unknowns)
val mode = rows.maxBy { it.probability }.days
val mean = rows.sumOf { it.days * it.probability }
val p80 = percentile(rows, 0.80)
// The refusal. A single number here is the substitution the whole post is about:
// whoever receives it will treat it as a commitment, and the mode is not one.
require(unknowns.isNotEmpty()) {
"no unknowns declared — that is a claim, not an estimate. If it is genuinely " +
"certain, say $baseDays days and commit to it."
}
if (p80 > mode * 2) {
return "mode ${mode}d, p80 ${p80}d — spread too wide to quote. " +
"Resolve ${unknowns.maxBy { it.probability * it.extraDays }.name} first."
}
return "likely ${mode}d · mean %.1fd · plan against ${p80}d".format(mean)
}
On the named case — the integration, with its three declared unknowns:
$ report(baseDays = 10, unknowns = listOf(
Unknown("webhook API version", 0.40, 4),
Unknown("compliance review", 0.30, 5),
Unknown("test/live key handling", 0.20, 3)))
likely 10d · mean 13.7d · plan against 17d
Those eight rows are worth seeing in full, because this is the whole post in one table:
days probability what landed
10 33.6% nothing
13 8.4% keys
14 22.4% webhook
15 14.4% compliance
17 5.6% webhook + keys
18 3.6% compliance + keys
19 9.6% webhook + compliance
22 2.4% all three
The mode really is 10 days — the single most likely outcome is still the one where everything lands well, at 33.6%, which is why it feels like the honest answer. The mean is 13.7, because the tail pulls it. The 80th percentile is 17. Three different numbers off one set of assumptions, and none of them is wrong; quoting the first as though it were the third is what costs somebody their plan.
Note also that nothing here is a 2× multiplier — 17/10 is 1.7, close to but under the 1.90 the history table measured. The arithmetic and the historical ratio are independent checks on each other, and they roughly agree, which is the only reason to trust either.
The refusal branch does not fire on this case, and it should not: a 1.7× spread is quotable with its drivers attached. It fires when one unknown dominates everything else —
$ report(baseDays = 10, unknowns = listOf(
Unknown("vendor can even do this", 0.50, 20),
Unknown("compliance review", 0.30, 5)))
mode 10d, p80 30d — spread too wide to quote. Resolve vendor can even do this first.
— and it names the unknown with the largest probability-weighted cost (10 expected days here, against compliance's 1.5), because that is the one whose resolution collapses the range fastest. On the original three, that ordering picks the webhook version at 1.6 expected days over compliance at 1.5 — close enough that the ordering flips with a small revision to either, which is itself a signal that both want resolving this week rather than one of them.
How we proved it
That the spread is real, from our own history. Ten completed projects, estimate against actual:
project estimated actual ratio
A 5d 6d 1.2
B 10d 11d 1.1
C 10d 25d 2.5
D 3d 3d 1.0
E 15d 19d 1.3
F 5d 14d 2.8
G 8d 9d 1.1
H 20d 31d 1.6
I 5d 5d 1.0
J 10d 12d 1.2
median ratio 1.25 mean 1.58 max 2.8 min 1.0
Two things in that table. The minimum is 1.0 — nothing ever came in early, which is the floor making the distribution skewed. And the mean is well above the median, which is the tail.
That the 80th percentile is a usable planning multiplier, derived rather than guessed:
p50 ratio 1.25
p80 ratio 1.90
p90 ratio 2.50
So "plan against roughly 2× the likely estimate" is not pessimism; it is the 80th percentile of our own measured history.
And that the new format changed outcomes on the following six projects:
before (10) after (6)
projects where a dependent team idled 4 0
median estimate-to-actual ratio 1.25 1.15
estimate revised before the midpoint 1 5
The last row is the mechanism working: five of six estimates were narrowed early because the unknowns were resolved first, so the plan was corrected while there was still time to act on it.
Two honest limits. Six projects is a small sample and the team also got more familiar with the domain over that period, so some of the 1.15 is learning rather than estimation practice. And the 2× multiplier is our history on this kind of work — the useful move is to compute your own ratio table, not to adopt this one.
What this generalizes to
Report the statistic your audience needs, not the one you computed. The mode is what your intuition produces; a plan needs a high percentile. Any time you hand over a single number, you have chosen a statistic on someone else's behalf — say which one it is.
Name the unknowns and the date they resolve. An estimate's uncertainty is not vagueness; it is information with structure. "Two or five weeks, decided by Wednesday" is more actionable than any point estimate, and it is checkable afterwards.
Resolve unknowns first. The natural order is to build the familiar part and discover the hard part late. Inverting it narrows the range while the plan can still change, which is the whole value of knowing.
Percentage-complete against an unknown total is noise. "80% done" three weeks running was honest each time. Tracking which unknowns remain is monotonic, specific, and cannot be 80% for a month.
Related
- The code review that should have been a conversation — another signal hiding in a process
- The most valuable pull request that quarter deleted 4,000 lines — work that never gets a ticket
- The third time this filled up, I diagnosed it as an outage — what unrecorded knowledge costs
Keep reading
This is part of 100 practices of senior engineers — the habits that separate five years of experience from one year five times. Follow MobileByteSensei for the rest.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".