Skip to content
September 19, 2026engineeringperformanceon-device

The freeze that hid itself: 24 minutes of pinned window, and why we couldn't profile it

A user opens a project. The editor stops responding. Not a spinner — the window itself: menus don't open, the caret doesn't blink, clicking a tab does nothing. Twenty-four minutes later it comes back as if nothing happened.

Here is the log line from the run we measured, on the desktop app, version 1.0.10, against a 1,341-file repository:

[oioxo] codebase index: 1339 of 1341 file(s) -> 5635 chunk(s) in 1457.8s

That is the semantic index — the thing that lets the agent find code by meaning rather than by string match. It was doing exactly what it was designed to do. It was just doing it in the worst possible place.

The bug was one word long

The embedding model is MiniLM, quantized to int8, running through ONNX Runtime with device: 'wasm'. The file that drove it, codebase-index.ts, had no Worker in it anywhere. So the model ran on the main thread — the same thread that paints the window.

The part that made this survive code review for as long as it did is that the loop looks asynchronous:

for (const batch of batches) {
  const vectors = await embed(batch);   // ~353 batches of 16, ~4.1s each
  store(vectors);
}

There is an await in there. await yields. Reviewers see await in a loop and read "this yields to the event loop, so the UI stays alive." And it does yield — between batches. The problem is what happens inside each one: a synchronous WebAssembly run that occupies the thread for about 4.1 seconds and cannot be interrupted. The renderer got the slivers between batches and nothing else. Three hundred and fifty-three times.

An await yields at the boundary. It does nothing about what sits on the thread between boundaries. If the work inside is synchronous, you have written a blocking loop with extra steps.

The freeze broke the tools for finding freezes

This is the part worth the post.

When the window is pinned, the things you would reach for stop working — and they stop working in ways that look like different bugs:

  • location.reload() returned without doing anything. Not an error. It just didn't reload.
  • DevTools Runtime.evaluate timed out, so remote debugging couldn't ask the page a single question.
  • Profiler.stop() could not return — because the profiler needs the same thread the freeze is holding.

That last one is the trap. The instrument you would use to find which thread is blocked runs on the thread that is blocked. You point the profiler at the problem and the profiler becomes the problem's next victim. It doesn't report "main thread busy"; it reports nothing at all, which reads like a broken profiler rather than a pinned renderer.

Three separate investigations in our own issue tracker died against this, each one concluding something different and each one wrong — a hung updater, a dead IPC channel, a broken reload. None of them were. They were all the same 1,457 seconds, wearing different masks. A freeze that disables the diagnostics is not one bug; it is a bug that manufactures more bugs on top of itself.

What we did not change

The obvious reading is "wasm was the wrong backend, use WebGPU." We kept wasm, and the original reasoning for it was never wrong.

ONNX Runtime's WebGPU session creation stalls rather than throwing when it can't proceed. It doesn't reject; it simply never settles. Silence is not a rejection you can catch, so a WebGPU-first path with a wasm fallback has no reliable moment to fall back at — you are left choosing between a timeout you guessed at and a hang.

So the backend choice stayed. The question it had never asked was not which runtime, but where that runtime runs. Those are separate decisions and we had been treating them as one.

We already had the answer in the codebase. webllm-worker.ts had existed for a while, for precisely this reason, with a comment saying so: the page hard-freezes for the whole generation otherwise. The text-generation path had learned this lesson. The indexing path had never been told.

Where the policy lives

Moving work to a worker raises a design question that is easy to get wrong: what else moves with it?

We kept both deadlines — the per-batch timeout and INDEX_BUILD_DEADLINE_MS, which bounds the whole build rather than each piece — on the host side. The worker batches what it is told to batch and reports what happened. It does not decide when to give up.

Splitting timeout policy across a thread boundary would have hidden half of it: you would read the host to learn the job can be abandoned, read the worker to learn a batch can be abandoned, and have to hold both in your head to answer "what happens to a repo too large to finish?" The whole-build deadline works unchanged after the move, because it races a promise and genuinely does not care which thread settles it.

Failures degrade toward the thread that already worked. No worker bundle (the client HEAD-checks first), a constructor that throws, a model that won't load — each falls back to the main thread, which is slower and was the old behaviour, so the worst case is the status quo rather than a new failure. And a worker that dies rejects its pending calls rather than leaving them unsettled. That detail matters more than it sounds: a hang with no thread pinned is harder to diagnose than the freeze we just removed. We did not want to trade a visible 24-minute freeze for an invisible permanent one.

A side effect worth naming: roughly 320 MB of ONNX Runtime wasm heap, which lives outside the JavaScript heap, moved out of the page along with the work.

What the user gets

The total indexing time is about the same. We did not make the embedder faster.

What changed is that you can use the editor while it happens. Type, open files, run the agent, switch projects. The reading proceeds behind the window instead of on top of it.

That is a smaller headline than "we made indexing 10× faster," and it is the honest one. The work was never the problem. The thread it sat on was.

---

Shipped in 1.0.11. Measured on a 1,341-file repository; the 1,457.8s figure is a real run on the desktop app, not an estimate.