Heavy Data Processing in JavaScript Without Freezing the UI

Have you ever clicked a button and watched the entire page freeze? No scrolling, no hover effects, just a stuck interface. This frustrating experience often happens when a web application needs to process a lot of data.

The detailed reason lies in how browsers work: JavaScript is single-threaded. This means it can only do one thing at a time. If it's busy processing a large dataset, it often blocks the main thread, stopping it from handling anything else—including rendering updates (frames), inputs, or animations.

What's tricky is that your site might score 100/100 on Lighthouse / Web Vitals like demo page and still suffer from this. If the freeze happens when a user clicks a "Filter" button after the page loads, your diverse metrics might look perfect while your user experience is broken.

Lighthouse 100 100 100 100

This could often be avoided by moving the processing to the server. But when client-side processing is required—offline support, real-time filtering, or reducing server load—you need a different approach.

The Problem

A sales dashboard fetches 100k records from an API and aggregates them by date for a chart. The aggregation loop blocks the main thread:

const response = await fetch('./data.json');
const rawData = await response.json();

// BLOCKS Main Thread
// The UI freezes completely while this loop runs
rawData.forEach(item => {
    // Heavy business logic or complex calculation
    performComplexCalculation(item); 
    
    // Aggregation logic
    updateStats(item);
});

renderChart(aggregated);

While processing, the entire page becomes unresponsive—no scrolling, no hover effects, no button clicks register.

See it in action: naive.html

The Chunking Workaround

One approach is to slice the work using setTimeout or requestIdleCallback:

function processInChunks(data, chunkSize, callback) {
    let index = 0;
    function nextChunk() {
        const chunk = data.slice(index, index + chunkSize);
        chunk.forEach(item => { /* process */ });
        index += chunkSize;
        if (index < data.length) {
            setTimeout(nextChunk, 0);
        } else {
            callback();
        }
    }
    nextChunk();
}

This keeps the UI responsive by yielding between chunks. But the total processing time increases due to the overhead of scheduling. It spreads the work out—it does not parallelize it.

Web Workers

The solution is to move the heavy lifting off the main thread. Web Workers allow you to run JavaScript in a background thread, completely separate from the UI.

By default, all your JavaScript runs on the same thread that draws pixels to the screen. If that thread is busy calculating numbers, it cannot update the interface.

Web Workers solve this by enabling true parallelism. They spawn a background thread with its own independent event loop and memory. This allows you to run expensive algorithms without ever impacting the smoothness of scrolling, animations, or button clicks.

In this architecture, the Main Thread remains dedicated solely to the UI, handling user input and animations so they stay smooth. Meanwhile, the Worker Thread takes over the heavy processing. Even if this worker thread blocks itself while crunching numbers, it doesn't matter because it's independent. The two threads exchange data asynchronously using a messaging system called postMessage.

worker.js

// worker.js runs in a separate thread
import { aggregateData } from './utils.js';

self.onmessage = async (e) => {
    // 1. Fetch data
    const rawData = await fetch(e.data.url).then(r => r.json());
    
    // 2. Heavy processing (happens in background)
    // The Main Thread remains free for UI updates!
    const result = aggregateData(rawData);
    
    // 3. Send result back
    self.postMessage({ type: 'DONE', payload: result });
};

main.js

The main thread delegates the work and receives the result when ready.

See it in action: worker.html

Web Workers add overhead from postMessage serialization that can sometimes offset their benefits, but they keep the main thread free so the UI stays responsive; there are additional optimizations available (transferable objects, SharedArrayBuffer, or WebAssembly.