DEV Community

Cover image for Your UI Isn't Slow Because of the Network – It's Your Main Thread
Abdul Halim
Abdul Halim

Posted on

Your UI Isn't Slow Because of the Network – It's Your Main Thread

Have you ever used an app where the data is already loaded, there's no spinner, no network request happening, and yet the moment you type into a search box, everything just... freezes for a second?

That's not a network problem. It's a main thread problem, and once you understand why, the fix is pretty simple.

Why This Happens

Your browser runs JavaScript on the same thread that draws the screen, plays animations, and listens to your keystrokes. This is called the main thread. When you run a heavy task, like filtering, sorting, or adding up data, directly inside your event handler, you block the very thread that keeps your app feeling smooth. The browser can't redraw the screen or respond to your typing until that task is done.

A Real Example

Picture an internal admin dashboard for an online store. Support staff need to search through 30,000 products, filter them by category, and sort by price or stock, all without waiting on the server for every keystroke. So the whole product list gets loaded into the browser up front.

Here's what tends to happen:

On the main thread, typing a search term can cause the page to visibly stutter or hang for a moment while the filter and sort logic runs.

Debouncing helps a little. It delays when the computation runs, so you're not filtering on every single keystroke. But once the computation does run, it still blocks the page for that moment.

Move that same computation into a Web Worker, and the interface stays smooth. The computation itself takes roughly the same amount of time, but the experience feels completely different, because the main thread is never interrupted.

The Real Fix

The fix isn't to make the task run faster. It's to move it off the thread that draws your screen.

In practice, this means sending your full set of data to the worker just once, not on every search. After that, you send small, light query objects each time the user searches, filters, or sorts. The worker does the hard work in the background and sends the result back. Meanwhile, your main thread stays free to draw the screen, scroll, and respond to whatever the user is doing.

One thing to watch out for: since workers reply asynchronously, results can come back out of order. If a user types fast, an older, slower query might finish after a newer one. The fix is to tag every query with an ID and only trust the reply that matches the most recent ID you sent. Anything else gets thrown away.

It's also worth knowing that workers can't touch the page directly. They're meant for pure computation, things like filtering, searching, adding up numbers, or image processing, not for changing what's on screen.

A Working Example

Here's what this looks like in code, using the product catalog example.

main.js (your app)

// Create the worker once
const worker = new Worker('productWorker.js');

// Send the dataset one time
worker.postMessage({ type: 'INIT', products: allProducts });

let latestQueryId = 0;

function runSearch(keyword, category) {
  const queryId = ++latestQueryId;
  worker.postMessage({ type: 'QUERY', queryId, keyword, category });
}

worker.onmessage = (event) => {
  const { queryId, results } = event.data;

  // Ignore results from outdated queries
  if (queryId !== latestQueryId) return;

  renderProductList(results); // safe to touch the DOM here
};

searchInput.addEventListener('input', (e) =>
  runSearch(e.target.value, categoryFilter.value)
);
Enter fullscreen mode Exit fullscreen mode

productWorker.js (the worker)

jslet products = [];

self.onmessage = (event) => {
  const { type, products: incoming, queryId, keyword, category } = event.data;

  if (type === 'INIT') {
    products = incoming; // stored once, reused for every query
    return;
  }

  if (type === 'QUERY') {
    // the heavy work happens here, off the main thread
    const term = keyword.toLowerCase();

    const results = products
      .filter(p => !category || p.category === category)
      .filter(p => p.name.toLowerCase().includes(term))
      .sort((a, b) => a.price - b.price);

    self.postMessage({ queryId, results });
  }
};
Enter fullscreen mode Exit fullscreen mode

The pattern is simple once you see it: send the data once, send cheap queries after that, tag each query with an ID, and only trust the response that matches your latest query. All the expensive work, the filtering, the sorting, and the aggregation happen inside the worker file, so the main thread stays free to keep your app responsive no matter how large the dataset gets.

When to Reach for This

If your app feels slow because of heavy JavaScript work, not because of the network or the screen drawing itself, a web worker is one of the best fixes you can reach for. You don't need it for simple, light logic; that would just add extra work for no reason. But for apps that handle large amounts of data right in the browser, it can completely change how the app feels to use.

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

This is so true! I often find myself reaching for