I built Paper Finder, a free tool that searches arXiv, Semantic Scholar, and Crossref at once, then re-ranks the results by semantic relevance.
The ranking uses a small AI model that runs entirely in the browser, so there are no API costs or accounts. But it introduced a nasty bug: typing in the search box could freeze for up to seven seconds.
The bug
Paper Finder initially sorted results by year. To improve relevance, I added Xenova/all-MiniLM-L6-v2 through transformers.js. It embeds the query and each result, then ranks them using cosine similarity.
The results appeared before re-ranking began, so I assumed the AI work was safely happening "in the background."
Then a user sent me a Chrome DevTools trace showing 1,350.9 ms of input delay. My first thought was, "That can't be the AI—it's async."
I was wrong.
Measuring the freeze
I used Chrome's PerformanceObserver with the longtask entry type, which reports tasks that block the main thread for more than 50 ms:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(`Blocked for ${entry.duration}ms`);
}
});
observer.observe({ entryTypes: ["longtask"] });
After clearing the browser's model cache and running a fresh search, I saw this:
{ duration: 7079, start: 13735 }
One task had blocked the main thread for 7,079 ms. During that time, the page couldn't process typing, clicks, or anything else.
Why await didn't help
The embedding call used await, but that doesn't move work off the main thread. It only yields while waiting for genuinely asynchronous work, such as network requests or timers.
WASM inference is synchronous CPU work. Wrapping it in a Promise changes how you receive the result, not where the computation runs. The model initialization and inference were still competing with rendering and input on the main thread.
The fix
I moved the model into a dedicated Web Worker:
// embedder.worker.ts
self.onmessage = async (event) => {
const extractor = await getExtractor();
const output = await extractor(event.data.texts, {
pooling: "mean",
normalize: true,
});
self.postMessage({ vectors: /* ... */ });
};
The main thread now sends text to the worker and waits for the vectors to come back. The public interface didn't need to change—a Worker-backed Promise looks the same to callers.
After repeating the cold-cache test:
longtasks: []
Zero long tasks, and the search box remained responsive throughout.
The main lesson: await does not mean "won't block the main thread." For client-side ML or any other CPU-heavy work, use a Web Worker from the start.
The source is on GitHub, and the live app is at paperfinder.dev.
Top comments (2)
The 7-second freeze is the kind of detail that makes a tool post useful. AI products often feel like model problems, but the user experience is usually won or lost in boring latency, loading states, cancellation, and whether users trust that anything is happening.
Yes, this was just a project I started to dive a little more in edge AI, building something that would actually be useful to me. Wasn't expecting a complex issue like this to happen.