Building Responsive Frontends for Data-Heavy Applications
A frontend walkthrough of data-heavy applications, main-thread blocking, and Web Workers
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.
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:
scan the full dataset
filter by time range and log level
match the search keyword
calculate summary counts
build timeline buckets
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:
the main thread sends some input
the worker performs the calculation
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.



