Skip to content
September 19, 2026engineeringperformanceweb

Our front page said 'AI features are disabled' for 40 seconds. The feature worked the whole time.

A new visitor opens oioxo.com. The editor paints. Above the composer, a label reads AI features are disabled.

It stayed there for about forty seconds on a cold load. The composer underneath it worked the entire time — you could type, send, and get an answer. It was a wrong label on a working control, which is in some ways worse than a broken control: a broken thing invites a retry, but a label that says the product doesn't work invites a close-tab.

It was also the first thing a new visitor ever saw.

342 requests to answer a yes/no question

The web tier ships 77 plugins. On boot, each one fetched its package.json and its package.nls.json at runtime. Languages added one language-configuration.json each. That comes to 342 requests, every one of them tiny.

Tiny does not mean free. Our Iceland host sits at roughly 450 ms round-trip for a lot of the world, and browsers open about six connections per origin. Three hundred and forty-two requests through a six-lane queue at 450 ms each is a traffic jam measured in tens of seconds — and the jam is almost entirely latency, not bytes.

The label was collateral damage from that queue. The chain:

canRun  ->  workspace trust  ->  preferenceService.ready

canRun decides whether to say "AI features are disabled." It reads workspace trust, which waits on preferenceService.ready. That promise resolves when preferences have loaded — and preferences were stuck behind 342 plugin manifests in the same connection pool.

Nothing in that chain is about AI. A queue of unrelated metadata fetches starved one promise, and a label three layers away rendered the pessimistic branch while it waited. The feature was never disabled. The thing that knew whether it was enabled simply hadn't been able to get a word in.

We had tried this before and reverted it

The fix is not clever: the build already knows what is in those manifests, so inline them and stop asking at runtime.

We shipped exactly that once, and reverted it, because the theme picker collapsed from 17 themes to 6. That revert was correct — a third of the themes vanishing is not an acceptable price for a faster boot — but the conclusion drawn from it was wrong. We filed it as "inlining breaks plugins" and left it alone.

The cause was in the change, not in the idea.

The inlining path built a synthetic manifest carrying only contributes. But the real shape is:

type RawManifest = { contributes, activationEvents }

and normalizeContributions reads activationEvents three separate times. Every inlined plugin therefore lost its activation events. Then a guard downstream —

if (!raw && !pkg.activationEvents) { /* drop it */ }

— dropped any plugin that had activation events and no contributes. The themes weren't broken by inlining. They were dropped by a filter reading a field the first attempt had silently stopped populating.

One missing key in a synthetic object, three reads away, behind a guard whose condition looked reasonable. The second attempt carried the whole shape, and the picker holds at 17.

The lesson we took: a revert records that something failed, not why. We had a one-line explanation ("inlining breaks the theme picker") standing in for a root cause nobody had found, and it kept a good change off the product for weeks.

Measured, at the round-trip that causes the problem

Local benchmarks are useless here. At 5 ms RTT the queue drains before you can see it, and the bug does not exist. So we measured on the prod host itself: the branch running as a container on port 3099 while live traffic stayed on 3077, both read through fresh tunnels minutes apart.

live (23db54a)this branch
Wrong label until40.8 snever
Plugin requests342126
Themes in picker1717

Two things about that table matter more than the headline.

The label is gone, not shortened. The composer appears at 9.7 s already carrying the correct label. We did not make a wrong thing appear for less time; we removed the window in which it could be wrong at all. Those are different fixes and only one of them is finished.

The live arm reproduced ~40 s and 342 requests three times across the session. A single before-and-after run proves nothing when the variable is network latency — the "before" could simply have been an unlucky minute. Re-running the control until it repeats is what makes the comparison mean anything, and it is the step that is easiest to skip once you already believe your fix works.

The theme count is in the table for the same reason: it is the thing that went wrong last time. A measurement that only reports the number you were trying to improve cannot tell you what you broke to improve it.

---

Shipped in 1.0.12. Measured on the production host at its real round-trip time, not locally.