The optimisation that would have bought one per cent
Two "make it faster" tickets on a bid-response system. A detail page that waited on a value nobody read, and an answering run that took over an hour.
Go
MongoDB
AWS Bedrock
AWS KMS
OpenSearch
Both of these arrived as "it's slow". Both had an obvious fix (cache the key, cache the prompt), and in both cases the measurement said the obvious fix was the wrong operation. The first one turned out not to need the value it was waiting for. The second was output-bound, not input-bound, which removes prompt caching from the table on arithmetic alone.
2 msp50 once an endpoint stopped waiting on a decrypt no handler read, while its siblings ran 174–304 ms
The problem
Two complaints, a day apart, about the same feature area.
The first: opening a bid took seconds. Not one request. The page fans out to about thirteen
endpoints, and the browser client requests each of them twice, so a load is roughly two dozen
requests and the slow ones were slow individually.
The second: answering one returnable workbook took over an hour of progress bar. That is an
unusual kind of slow. Nobody sits and watches it, so it is not experienced as latency; it is
experienced as not knowing whether the run is alive, which is a different complaint wearing the
same words.
01
Two obvious fixes, both wrong
Cache the decryption key; cache the model prompt prefix. Each is the first thing anyone suggests, each is about thirty lines, and the measurements killed both. One because the value being decrypted was never read, the other on arithmetic.
02
The answering path must not get less accurate
There is a fast version of this system: shorter answers, less retrieved material, a smaller model. Every one of those is an accuracy trade dressed as a speed win. The brief was to find the speedups that are free, and to name the ones that are not as trades rather than shipping them quietly.
03
Nothing was instrumented
The per-call token usage was already being returned by the model SDK and thrown away. A rate-limit rejection was one log line with no counter. So the first question, is this happening today, had no answer, and no amount of code reading produces one.
04
Every knob is shared by three callers
Batch size, batch concurrency, retrieval budgets and the retry budget are package-level constants with three entry points, and each caller runs under a different deadline. A tuning change scoped to the caller you were looking at lands in two others.
What this was
Stated plainly, because the two halves are not at the same stage: the page-latency work shipped.
The throughput work is a measurement and a plan, and no knob has been turned. One of its
proposed changes was dropped outright, which is the part of it I think is worth reading.
Already existed
Envelope encryption throughout: one data key per entity, wrapped by a managed key service, with the wrapped key stored on the document
An established codebase pattern of decrypting once per entity and threading the plaintext key into children, used correctly elsewhere
A presence-check repository method written expressly so an existence test could not be confused with a decryption failure, which is the precedent the fix extends
Batched, concurrent answering with per-batch retrieval, a per-tab deadline and a staleness-based resumer for abandoned runs
A single bounded retry with backoff on rate-limit errors, added shortly before this work and, as it turned out, undocumented in the place that matters
What I added
Per-endpoint latency percentiles from the running stack, with one endpoint identified as a control variable: same middleware, same database, no decryption
An audit of all thirty-nine gate call sites and roughly twenty direct ones, establishing that the decrypted field is read by nothing anywhere in the codebase
A non-decrypting read and two non-decrypting gates, plus the invariant that keeps them away from the whole-document write path, asserted by a test
A live measurement of a real answering run: batch count, wave timing, tokens in and out, and where the wall clock actually goes
The finding that batch latency is output-bound, which reorders the whole plan and drops prompt caching on two independent counts
A sequenced plan (instrument, then make the loss visible, then step the concurrency, then reshape the batch) with one variable per run and the stale premises that argue against it named for rewriting
One compiled-in constant, three entry points, three kinds of deadline
Five problems worth describing
01
The endpoint that returned in two milliseconds
Symptom
A bid detail page taking seconds to appear locally, with no obvious culprit: the database holds
two documents of under a kilobyte, fifty lookups run in forty-one milliseconds, every container
is under two per cent CPU, and the page shell itself serves in two milliseconds.
Diagnosis
One endpoint in the table was the answer. Listing the assignable users for a bid returned in
2 ms at p50 while its neighbours ran 174 to 304 ms at p50 and up to 6.8 seconds at p99. It
shares the authentication chain, the permission middleware and the database with every other row.
The only thing it does differently is that it does not decrypt anything.
That made it a control variable rather than a hypothesis, and it ruled out the whole middleware
stack in one reading. A round trip to the key service from that machine measures about 150 ms:
41 ms to establish the connection and 100 ms for the handshake, which is precisely the floor
sitting under every other row.
Then the part that decided the fix. The shared gate decrypts exactly one field. I enumerated all
thirty-nine call sites: twenty-six discard the loaded record entirely, wanting only an
existence-and-authorisation check plus an identifier, and the thirteen that keep it read only
plaintext columns. Not one of them reads the decrypted field. Nor does anything else: its only
other appearances are the crypto functions themselves and one write path. Seven of the thirteen
then make a second round trip on the same key.
A further twenty call sites bypassed that gate altogether, and two of them were on the page’s hot
path. One used a full decrypting read as a bare existence check and then re-read the same record
to fetch its key, paying two database reads and two key round trips per request. A page load spent
roughly twenty-eight key round trips, nearly all of them to produce a string nobody looks at.
Decision
Not a key cache. A time-limited cache of plaintext data keys would have fixed this everywhere in
about thirty lines, and I wrote down why I was not doing it: it holds key material in process
memory past the request that needed it, it removes the per-decrypt audit record from the cloud
trail, and it defers key revocation by the length of the cache lifetime. When almost every call
turns out to be unnecessary, not asking is both faster and tighter than caching the answer.
So: a non-decrypting read for handlers that touch only plaintext fields, a non-decrypting
existence gate for the twenty-six that discard the record, and the redundant gates in front of
key fetches downgraded to the presence check that already existed. No new mechanism, no route
changes, no response-shape changes.
The two ways to make a request stop waiting on a key service
Cache the answer
mechanism TTL cache of plaintext data keys
cost ~30 lines, fixes every call site
key material lives past its request
audit trail loses the per-decrypt record
revocation delayed by the TTL
and every needless decrypt is still there
Stop asking the question
mechanism three reads that do not decrypt
discards the record → existence gate
plaintext fields → non-decrypting read
needs the key → unchanged
key material never outlives the request
audit trail intact · revocation immediate
encrypted field returned BLANK, not as bytes
Two details carry more weight than the speedup. The non-decrypting read blanks the encrypted
field rather than passing the ciphertext through, because serving encrypted bytes labelled as
prose is a silent failure and an empty field is a loud one. And the invariant: nothing from a
non-decrypting read may reach the whole-document write path, which re-encrypts that field and
replaces the document, so an empty value arriving there is data loss rather than a no-op. The two
read-modify-write callers keep the decrypting read, deliberately, and a test asserts both halves:
that the raw read blanks the encrypted field, and that it does not blank the plaintext columns
its callers exist to read.
02
Output-bound, not input-bound, so the obvious optimisation was dropped
Symptom
A 301-question tab answered in eight batches of forty, two batches in flight, about 6.4
minutes per wave and roughly 26 minutes for the tab. Tabs run strictly one at a time, so a
fifteen-tab workbook is 70 to 80 minutes. The prompt for one batch is around 245 KB. The obvious
read is that the prompt is enormous, so cache its stable prefix.
Diagnosis
A batch is one model call. Its input prefills in tens of seconds; its output is forty
answers of prose, 10 to 16 thousand tokens, and that generation is five to six of the 6.4
minutes. The call is output-bound.
That single sentence reorders everything, because prompt caching acts on the input side. Two
independent counts finished it off.
The byte-stable prefix is about 4–5% of the prompt. Roughly 10–12 KB of system prompt and
preamble against 245 KB, because the retrieved material differs per batch by design. Discounting
4% of a prefill that is itself the minority of an output-bound call is on the order of 1% of wall
clock. And the prefix is not even strictly stable: parts of it are conditional on what a given
batch contains.
The cache lifetime is shorter than the gap between waves. The available cache entries expire
in five minutes and the SDK version in use offers exactly one lifetime setting, so every wave
after the first would find the entry expired and pay the write premium again. The mechanism would
run, cost more, and produce nothing, which is the worst of the available outcomes, because it
yields cost with no signal.
Decision
Dropped, with both counts written down and a specific condition for revisiting it: if per-batch
latency ever falls under five minutes, waves fall inside the cache lifetime and it becomes worth
its 4%, as a cost optimisation rather than a speed one.
What the output-bound finding leaves is exactly two levers: more calls in flight, or fewer output
tokens per call. And it rules a third one out on the record. Shortening the answers would cut
the dominant cost directly, and that is precisely the accuracy trade this exercise existed to
avoid. Raising the batch size is also out: 64% of the per-call timeout is already consumed, and
on an output-bound call a bigger batch buys timeout risk in direct proportion.
Instrument first, and not as diligence theatre. It answered two of the four open questions by
observation instead of argument. The per-call token counts were already being returned by the SDK
and discarded, and rate-limit rejections had no counter, so are we being throttled today and
is the binding limit our concurrency or the account’s output quota were both unanswerable. One
day’s work, no behavioural risk. The one wrinkle found while scoping it: the usage struct is
serialised to the browser verbatim by an unrelated chat feature, so adding fields to it is a wire
change rather than an internal one, which decides how they are named, or whether a local type is
used instead.
Where the time actually went: two measurements, two units, not one axis
The two bands are on separate scales on purpose. The upper one is a cost breakdown and not an
achievement: nothing in it was made faster, and its conclusion was to not ship the change. The
lower one is the saving. Keeping them on one axis would have made the millisecond work invisible and
implied the two were the same kind of result.
03
An un-retried throttle is not a slowdown. It is a silent quality loss.
Symptom
Concurrency was capped low. The comment on the cap explained why: rate limits exist and this
service has no retry or backoff, so a throttled batch is a degradation rather than a failure, its
questions fall back to needs-manual-entry, and a conservative cap therefore costs a few manual
questions at worst.
Diagnosis
Read that again as a user experience. A throttled batch does not fail the run. The run
completes, reports as complete, and is quietly forty questions thinner, and nothing counts
that. This is not a throughput problem that also has a quality wrinkle; it is a silent
correctness problem being described as a rate-limit accommodation.
Which reverses the ordering of the whole plan. Backoff is not a prerequisite for going faster; it
is the accuracy fix, and it happens to also be the prerequisite. It must land, and be observed
working, before any concurrency number moves.
Then the part I would have got wrong by reading fast: retry is not absent, it is thin. A
single bounded retry on throttling had landed shortly before, and the comment on the concurrency
cap still asserts there is none. Two comments, in fact, both arguing from a premise that was no
longer true, including one whose entire justification for halving the cap is a retry gap that
has since been partly closed.
Decision
Rewrite the comments in the same change that moves the number. This sounds like tidying and is
not: the alternative is a constant guarded by an adjacent rationale that argues against the edit
you just made, which is how a number gets reverted by the next person to read it. And the cap
steps one at a time, with the throttle counter read between steps, because “we raised it and
nothing broke” is not a reading when the failure mode is invisible by construction.
The comment on a concurrency cap, before and after the retry it does not know about
The premise that had expired
“…this service has no retry/backoff on
throttling, so concurrency is capped
rather than unbounded.”
“A throttled batch still degrades
gracefully … a conservative cap costs a
few extra manual-entry questions at
worst, not a broken run.”
false since the retry landed, and the
reassurance IS the defect
What the comment has to say instead
one bounded retry on throttling exists;
that is what lets this number move.
an un-retried throttle is not slowness.
it is a whole batch of answers lost, on a
run that still reports success. hence the
throttle count and retry outcome in the log.
step the cap one value at a time and read
that counter between steps.
bounded by the silent interval the watchdog
tolerates. derivation written down here.
04
One constant, three deadlines, and a watchdog derivation that was not the mechanism
Symptom
Every knob involved (batch size, concurrency, retrieval budgets, retry budget) is a
package-level constant. The work was framed as tuning the returnable-tab path, which is where the
hour-long run was observed.
Diagnosis
There are three callers of the answering routine and only one of them has tabs. Response-grid
generation runs under a single hard deadline for the entire run, keyed off when the run
started, which no progress write can extend. Requirement validation runs synchronously inside a
live HTTP request, where backoff means a person watching a request hang and a proxy timeout
that will cut it long before any retry budget of ours expires.
So the blast radius is not returnables, and a single compiled-in retry budget cannot be correct:
the three deadlines differ in kind, not in size. The first vetting pass of this plan reasoned as
though the tab path were the only caller. A second pass found it, along with a test the first
pass had declared unaffected, which would have shipped broken.
The watchdog reasoning had the same shape of error, in the codebase rather than the plan. A
comment derives worst-case tab silence from the call timeout plus a retry, arriving at about
twenty minutes, and the resumer’s staleness window is sized against that. It is a sound
derivation of a bound the code does not rely on: progress is written after every batch, and that
write unconditionally touches the very timestamp the staleness filter matches on. Legitimate
silence is therefore one batch, not a whole tab. The genuinely exposed cases are narrow and
specific: a single-batch tab, and the last batch of any tab, where there is no subsequent batch
to touch the clock.
Decision
Make the retry budget caller-supplied, and write down which deadline each value was derived
from. Rewrite the watchdog comment around the per-batch touch that is the actual mechanism,
because otherwise the rewrite re-enshrines a plausible fiction, and the next person sizes a
timeout against it. Test the single-batch tab specifically, because it is the only case where a
retry produces real silence. And in the validation path, ensure the retry loop aborts on a
cancelled request rather than sleeping through one.
The fail-open lane one layer over is the trap I would most expect to be missed. Raising answering
concurrency also doubles the concurrent retrieval calls behind each batch, and that lane fails
open by design, against a different service quota. So the answering-side throttle counter can
read clean while retrieval context quietly thins: the exact silent loss this whole plan exists to
close, reintroduced one lane over, and invisible to the instrument built to watch for it. Hence a
separate per-batch count of retrieval failures, in place before the cap moves.
05
Halving the batch is not halving the input
Symptom
The remaining lever is fewer output tokens per call, which means fewer questions per batch. That
looks like a one-constant change: halve the batch size, per-call output halves, per-call latency
roughly halves, and batch count doubles so the concurrency has something to work on for small
tabs too.
Diagnosis
Halving the batch alone changes what every question sees, in two directions.
The retrieved question-answer block is always filled to its character budget: the merge takes the
best per question up to a cap, then backfills unspent budget with the next best of what is
left, zero-scoring pairs included last. Halve the questions and leave the budget alone and the
block does not shrink. It renders the same volume over half as many questions, with the tail made
of material that lost. That is a pure increase in near-miss context, and near-miss context
degrades answers rather than diluting them harmlessly.
The library lane failed for a different reason than the plan claimed. The original argument was
that the budget was starving the per-question allocation. The run says otherwise: 1 to 1.8 chunks
admitted per question against a ceiling of three, so the admission threshold is what binds, not
the character budget, and halving a budget cannot restore what a threshold already rejected.
Worse, the risk direction reverses: if real rendered size per chunk exceeds a computable figure,
the halved budget starts binding and per-question volume falls silently. Nobody measures
rendered characters today, only counts. That is one of the things the instrumentation adds, and it
has to be read before this change ships.
Decision
Halve the batch size and both character budgets together, so each question’s material stays
approximately as it is, and the change buys only throughput. State the part that is not neutral
rather than claiming it is: fewer questions per batch means less cross-question deduplication, so
the exact rendered set shifts slightly even with per-question volume preserved. Verified by
comparing retrieved-material-per-question before and after, plus a spot-check against the one
corpus item that has human-written ground truth.
And do not claim the latency halves. A per-batch fixed cost does not: the retrieval index is
rebuilt over the whole corpus on every batch, so doubling the batch count doubles that work
rather than halving it. The net is still a win because the embedding fan-out does halve, but the
instrumentation has to separate retrieval from generation or the result reads as an
under-delivery when what actually happened is a fixed cost being paid twice as often.
One variable per run, and one sequence: instrument, make the loss visible, step the concurrency,
then reshape the batch, on a separate run. Two of these changed together would leave neither
result readable. Two more things stayed out on purpose: moving the answering model to a smaller
one, which is the one change here that genuinely trades accuracy for speed and was a deliberate
earlier decision, and parallelising tabs, where the run claim, the per-tab claim, the staleness
window and the tab deadline are all built around one tab at a time. That is reworking the claim
model for a win that only shows up on large workbooks.
Where it landed
2 msp50 on the one endpoint that decrypts nothing, where its neighbours ran 174–304 ms sharing the same middleware and database
~28key-service round trips per page load, nearly all of them to decrypt a field no handler reads
6.4 minper batch of 40 answers, of which 5–6 minutes is output generation, which is why prompt caching was dropped
~4%of the prompt is byte-stable, so the dropped optimisation was worth roughly 1% of wall clock at best
Verified
The page-latency work shipped: a non-decrypting read, a non-decrypting existence gate, both wrapper handlers given siblings, and the redundant gates in front of key fetches downgraded to a presence check
A test asserts both halves of the raw read, that it blanks the encrypted field and that it does not blank the plaintext columns its callers exist to read, since over-broad blanking would be as bad as none
The read-modify-write callers deliberately keep the decrypting read, because the write path re-encrypts that field and replaces the whole document, so an empty value arriving there is data loss
The key cache was rejected on written grounds: key material outliving its request, the lost per-decrypt audit record, and revocation deferred by the cache lifetime
The throughput plan is measured rather than reasoned. Batch count, wave timing, tokens in and out, and admitted retrieval volume all come from one real run
Prompt caching is dropped with two independent counts and a specific condition for revisiting it, rather than left as an open idea that would be re-proposed every quarter
The plan is sequenced with one variable per run, and the stale code comments that argue against its own changes are named for rewriting in the same commits
What this doesn't solve
None of the throughput work has shipped. The projected end state, a 301-question tab from about 26 minutes to 6–8 and a workbook from 70–80 to 25–30, is a projection from the measured per-batch figure and the output-bound model. It is stated here as a projection because that is what it is.
Whether rate-limit rejections are happening today is still unknown, and no amount of code reading answers it. That is the whole reason instrumentation is the first ticket rather than a nicety.
Whether the binding limit is our own concurrency or the account output quota is likewise unresolved. The concurrency plan stops making sense at whichever comes first.
The admission threshold behind the library lane was calibrated against a corpus well under half its current size, and a planned ingestion adds hundreds more documents. Recalibration belongs to that work, against the corpus that will actually ship, which is why this plan deliberately does not touch the threshold.
The response-grid path has no accuracy baseline at all, so shared-constant changes would alter its output with nothing to compare against. Either it gets a before-and-after of its own or the gap is accepted explicitly; doing neither is how a regression is found by a customer.
The client requests every endpoint on the page twice. Halving that is free latency and it is in a different repository, so it stayed out of scope rather than being quietly folded in.
A neighbouring endpoint makes one key round trip per row because that collection carries a key per record, which the thread-the-key pattern cannot collapse. Left as its own ticket. While reading it I found it ships every referee’s full contact details on page load for a screen that displays two derived flags, which is a disclosure problem rather than a latency one.
What I'd do differently
Find the control variable first. The endpoint that returned in two milliseconds through the same
authentication chain, the same permission middleware and the same database was worth more than every
other measurement combined, because it eliminated all of them at once. I got there after ruling
things out one at a time. Looking for the fastest thing that shares the most machinery is a cheaper
first move than profiling the slow thing.
Ask what the call is bound by before optimising it. “The prompt is 245 KB” made prompt caching
feel obvious and it was pointing at the wrong side of the call entirely. One measurement of where the
6.4 minutes goes, tens of seconds in and five to six minutes out, invalidated a change that would
have taken real plumbing to build, and would have shipped a cost increase reported as a win. The
same optimisation was worth adopting on
a different workload in the same quarter, where a large reference
block genuinely is byte-stable and the call is input-dominated. Nothing about the technique changed
between the two decisions; the shape of the workload did, and it was measurable in both cases before
anything was written.
Treat a graceful degradation as a defect until someone counts it. The most useful sentence in
this whole body of work was already written in the codebase, as reassurance: a throttled batch
degrades gracefully to manual entry. That is a run reporting success while quietly dropping forty
answers. A degradation nobody counts is indistinguishable from correct behaviour, and it will be
described as a feature in the comment that introduced it.