The Pragmatic Developer

The Pragmatic Developer

Building Responsive Frontends for Data-Heavy Applications

A frontend walkthrough of data-heavy applications, main-thread blocking, and Web Workers

Juntao Qiu's avatar
Juntao Qiu
Jul 14, 2026
∙ Paid

Have you ever worked on a frontend feature where the data has already loaded, but the UI still feels slow?

Maybe it is a large table, an analytics dashboard, or a map containing thousands of markers.

The user types into a search box, changes a filter, or selects a different time range. There is no API request happening, but the page still freezes for a moment. The input feels delayed. Even the loading spinner stops spinning.

This is common in data-heavy frontend applications.

The bottleneck is not always the network. Sometimes, the expensive part is the work the browser performs after the data has arrived.

In this issue, we will walk through a small log analyzer—a mini Splunk-style incident dashboard—and see what changes when we move CPU-heavy analysis from the main thread into a Web Worker.

The frontend should stay lightweight—but not always

As a general rule, the frontend should focus on rendering, user interaction, and coordinating data. Heavy computation is often better handled by backend services.

That is still a good default.

However, if a large dataset is already loaded and the user wants to explore it locally, sending another backend request for every search query or filter change may be unnecessary.

A log analyzer is a good example.

turned on flat screen monitor
Photo by Chris Liverani on Unsplash

The user may want to filter logs by keyword, time range, log level, service name, or error message. When the data is already in the browser, local analysis can feel almost instant.

Until the dataset becomes large enough.

A log analyzer with 50,000 events

In the demo application, the server sends 50,000 log entries to the browser once. The full dataset is around 10 MB.

After the initial request, everything happens on the client:

  • keyword search

  • log-level filtering

  • time-range presets

  • timeline generation

  • summary calculations

  • a paginated table showing 50 rows per page

The server provides the raw log events, but it does not perform the analysis.

The dataset also contains a simulated production incident.

Around the middle of the afternoon, payment-service starts reporting repeated errors from PriceCalculator.applyDiscount. The error volume grows for around 30 minutes and then suddenly stops. After that, normal checkout traffic returns.

You can search for applyDiscount or checkout-v2, filter by ERROR, and inspect the timeline to understand what happened.

The incident itself is interesting, but the main point is what happens to the UI while you investigate it.

The table shows only 50 rows, so why is it slow?

The table displays only 50 rows at a time.

At first, that might make the application seem lightweight. Rendering 50 rows should not be particularly expensive.

But rendering is only the final step.

Whenever the user changes a filter, the frontend must:

  1. scan the full dataset

  2. filter by time range and log level

  3. match the search keyword

  4. calculate summary counts

  5. build timeline buckets

  6. return the rows for the current page

The expensive work happens before those 50 rows are rendered.

Here is the core analysis function:

export function analyzeLogs(
  sourceLogs: LogEntry[],
  query: LogQuery,
  requestId: number
): AnalysisResult {
  const keyword = query.keyword.trim().toLowerCase();
  const timeline = createTimeline(
    query.fromMs,
    query.toMs,
    bucketCount
  );
  const matchedLogs: LogEntry[] = [];

  // the actual time consuming computation...

  return {
    requestId,
    filteredRows: matchedLogs.slice(
      startIndex,
      endIndex
    ),
    totalMatches: matchedLogs.length,
    aggregations,
    timeline,
    durationMs: performance.now() - start,
  };
}

There is nothing particularly unusual about this code. It is a straightforward loop over the logs.

However, when it runs synchronously on the main thread, everything else has to wait.

The browser cannot respond smoothly to input. React cannot render the next update. Animation frames are delayed. Even a loading indicator may appear frozen.

The calculation does not need to be badly written to cause a problem. It simply needs to occupy the main thread for long enough.

Debounce helps—but it does not make the work non-blocking

The search input in the demo uses a 300-millisecond debounce.

That is useful because it prevents the analysis from running after every keystroke. But once the delay finishes, the calculation still runs on the main thread.

In the demo, one analysis can take around 400 to 500 milliseconds. During that time, the frame rate drops and the interface becomes unresponsive.

This reveals an important difference:

  • Debounce changes how often the work runs.

  • A Web Worker changes where the work runs.

Debounce reduces unnecessary calculations. It does not move those calculations away from the main thread.

Web Workers improve responsiveness, not necessarily speed

A Web Worker allows JavaScript to run on another thread, separate from the main browser thread.

The main thread can send a query to the worker:

const worker = new Worker(
  new URL(
    "../workers/analytics.worker.ts",
    import.meta.url
  ),
  { type: "module" }
);

worker.postMessage({
  type: "QUERY",
  requestId,
  query,
});

worker.onmessage = (event) => {
  if (event.data.type === "RESULT") {
    onResult(event.data.result);
  }
};

The worker receives the message and performs the analysis:

self.onmessage = (event) => {
  if (event.data.type === "INGEST") {
    sourceLogs = event.data.logs;

    self.postMessage({
      type: "INGESTED",
      total: sourceLogs.length,
    });

    return;
  }

  if (event.data.type === "QUERY") {
    const result = runWorkerDemoAnalysis(
      sourceLogs,
      event.data.query,
      event.data.requestId
    );

    self.postMessage({
      type: "RESULT",
      result,
    });
  }
};

A worker cannot update the DOM or render React components directly.

Instead, it follows a simple model:

  1. the main thread sends some input

  2. the worker performs the calculation

  3. the worker sends a result back

That makes workers a good fit for tasks such as parsing, searching large datasets, aggregation, image processing, and other CPU-heavy work.

However, the key point is this:

The Web Worker does not necessarily make the calculation faster.

In the demo, the analysis takes a similar amount of time in both modes.

The difference is that, in worker mode, the calculation does not block the main thread. The browser can continue responding to input, updating animations, and rendering the interface.

The work may take the same amount of time. The user experience is very different.

Good worker design is mainly about data flow

Moving a function into a worker is only part of the solution.

You also need to think about what crosses the boundary between the main thread and the worker.

User's avatar

Continue reading this post for free, courtesy of Juntao Qiu.

Or purchase a paid subscription.
© 2026 Juntao Qiu · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture