RSS Amplifier

The Pragmatic Developer · Jul 14, 2026

Building Responsive Frontends for Data-Heavy Applications

0
Sign in to vote or save

Juntao Qiu · The Pragmatic Developer

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.

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.

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.

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 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.

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.

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.

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.

Repeatedly sending all 50,000 logs for every query would introduce unnecessary transfer cost. The demo uses a better flow:

  1. Send the large dataset to the worker once.

  2. Send a small query object whenever the filters change.

  3. Return only the information the interface needs.

GET /api/logs
      │
      │ 50,000 log entries, fetched once
      ▼
Main thread: React UI
      │
      │ postMessage({
      │   type: "QUERY",
      │   keyword,
      │   level,
      │   page,
      │   ...
      │ })
      ▼
analytics.worker.ts
      │
      │ filter and aggregate the full dataset
      ▼
Main thread
      ▲
      │ current page, summary, timeline, and metadata
      │

The large input crosses the boundary once. Each later message contains only a small LogQuery object.

The worker also returns only what the interface needs: the timeline, summary values, metadata, and 50 rows for the current page.

Both the main-thread and worker modes use the same analyzeLogs function. The execution location changes, but the business logic stays shared.

That reduces the chance of maintaining two implementations that slowly drift apart.

Once the work moves into a worker, it is no longer a normal function call. The main thread sends a message and receives another message later.

That means the interface must protect itself from stale results.

Each query in the demo receives a requestId. When a result arrives, the application checks whether it still belongs to the latest query:

if (
  data.result.requestId !==
  latestRequestIdRef.current
) {
  return;
}
callbacksRef.current.onResult(
  data.result
);

If the result is old, the application ignores it.

This is similar to handling stale network responses. Moving computation to another thread introduces some of the same concerns: message ordering, errors, cleanup, and lifecycle management.

I would not use a worker for every piece of frontend logic.

Different bottlenecks require different solutions:

BottleneckConsider firstSlow network requestscaching, prefetching, better API designToo much JavaScript loaded initiallylazy loading, code splittingToo many DOM elementspagination, virtualizationRepeated calculationsmemoization, indexing, better data structuresCPU-heavy JavaScript blocking the main threadWeb Worker

A worker is useful when the application already has the data, but processing that data blocks rendering and interaction.

It will not fix every performance issue.

If the page freezes because it renders 50,000 DOM nodes, you probably need virtualization. If the calculation repeatedly scans the whole dataset, a better index or data structure may reduce the work more effectively.

These techniques can also be combined:

  • debounce to reduce how often analysis starts

  • an index to reduce how much data is scanned

  • a Web Worker to keep the remaining work off the main thread

  • pagination or virtualization to reduce rendering cost

The useful question is not simply, “Is the dataset large?”

It is:

Does this calculation occupy the main thread long enough to affect rendering and interaction?

When the answer is yes, a worker is worth considering.

In data-heavy frontend applications, the bottleneck is not always the backend.

Sometimes the data is already loaded, but the browser still needs to perform a large amount of work before it can display the result.

Caching, pagination, virtualization, memoization, and debouncing all solve different problems. None of them automatically move CPU-heavy JavaScript away from the main thread.

Web Workers allow us to run that work on a separate thread, leaving the main thread available for rendering and interaction.

The calculation may take the same amount of time. The application simply feels much better while it is happening.

For frontend system design interviews—and for real production systems—this is the kind of trade-off worth being able to explain clearly:

  • What is the actual bottleneck?

  • Why is debounce not enough?

  • What work should move into the worker?

  • What data should cross the thread boundary?

  • Is the additional complexity worth it?

Senior-level frontend engineering is not only about knowing that Web Workers exist.

It is about recognising when the main thread is the bottleneck and designing the surrounding data flow carefully.

Clone the frontend-system-design-patterns repository and start the log analyzer:

npm run setup:log-analyzer
npm run dev:log-analyzer

Open http://localhost:5173.

Switch between main-thread mode and worker mode, then watch the FPS indicator while searching for applyDiscount.

The analysis takes a similar amount of time in both modes. The difference is how the interface behaves while that work is running.

Watch the walkthrough:

Go deeper: I cover topics like this—including data-heavy interfaces, caching, rendering strategies, and production trade-offs—in Frontend System Design Essentials.

What is the most painful data-heavy interface you have worked on?

Did you optimise the work on the main thread, move it into a worker, or push the calculation back to the backend?

Reply and tell me what broke first.

No posts

Read the original on juntao.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.