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
One set of compiled-in knobsbatch size · concurrency · retrieval · retrytuning any of thesetunes all three lanesReturnable tabs40 min per tabreclaimed after 25 min silentprogress is written after eachbatch, and that refreshes thestaleness clock, so realsilence is ONE batch, not the20 min the comment derivedResponse-grid generation30 min, whole runmeasured from the run’s starta hard ceiling on everything.no touch extends it, noheartbeat saves it: overrunand the context dies, takingthe whole grid, not one batchRequirement validationa live HTTP requestclient patience, then ingressbackoff here is a personwatching a request hang, cutoff by a proxy timeout longbefore any budget of oursexpiresThese deadlines differ in KIND, not in size. So no single compiled-in retry budget is correct:the caller supplies it, and the deadline it was derived from gets written down next to it.

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
One batch of 40 answersmeasured: 6.4 min · a cost breakdown, not a savingWall clock, to scaleoutput generation · 5–6 minthe rest≈1% of wall clock: everything prompt caching could reach, at best.Only ≈4% of the prompt is byte-stable. Output-bound runs cannot befixed by caching the input, so it was dropped rather than shipped.Three sibling endpoints, p50measured · same middleware, same database, own scaleSlowest neighbourdecrypts the unread field304 msIts siblingsame middleware, same database174 msThe non-decrypting readsame middleware, same database2 ms≈28 key-service round trips a page load, nearly all to decrypt a field no handler reads.

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.