Skip to content
md.mudassir
← all writing

· 3 min read

Keep the main thread boring: web workers in a data-heavy ERP

An ERP screen froze every time someone touched a filter. Moving the heavy lifting into a web worker fixed it — without a rewrite.

frontendperformancejavascript

The bug report said "the app crashes when I filter." It didn't crash. It froze for a few seconds while JavaScript sorted, grouped and aggregated a few thousand rows — on the same thread that's responsible for painting the screen and responding to clicks. To the user, that's indistinguishable from a crash.

Find the work that doesn't need the DOM

Open the Performance panel, record a filter change, and look for long tasks. In our case nearly all of the time was in three pure functions: filter, group, aggregate. They took plain data in and returned plain data out. No DOM, no React state — which makes them perfect candidates for a worker.

That's the real test: if a function only needs its arguments, it can run on another thread.

Move it into a worker

Modern bundlers understand this pattern out of the box:

report.worker.ts
import { aggregate, filterRows, groupBy } from "./report-math";
 
self.onmessage = (e: MessageEvent<{ id: number; rows: Row[]; query: Query }>) => {
  const { id, rows, query } = e.data;
  const result = aggregate(groupBy(filterRows(rows, query), query.groupBy));
  self.postMessage({ id, result });
};
const worker = new Worker(new URL("./report.worker.ts", import.meta.url), { type: "module" });

Wrap it in a promise

Raw postMessage is awkward to use from components. A tiny wrapper with request IDs turns it into an async function — and lets us ignore stale responses when the user changes the filter again before the last one finished.

run-in-worker.ts
let nextId = 0;
const pending = new Map<number, (value: Result) => void>();
 
worker.onmessage = (e) => {
  pending.get(e.data.id)?.(e.data.result);
  pending.delete(e.data.id);
};
 
export function computeReport(rows: Row[], query: Query): Promise<Result> {
  const id = nextId++;
  return new Promise((resolve) => {
    pending.set(id, resolve);
    worker.postMessage({ id, rows, query });
  });
}

From the component's point of view, the expensive part is now just await computeReport(rows, query), and the UI keeps responding while it runs — spinners spin, inputs type, buttons click.

Mind the copy

Data sent to a worker is copied with the structured clone algorithm. For a few thousand objects that's cheap compared with the work itself, but it isn't free. Two things helped:

  • Send the dataset once. Post the rows to the worker when they load and keep them there; subsequent messages carry only the query.
  • Use transferables for big numeric data. An ArrayBuffer can be transferred instead of copied — ownership moves to the worker in constant time.

The result

Filtering went from freezing the screen to feeling instant, and the change touched a handful of files. No framework migration, no rewrite — just moving work to where it belongs.

The broader habit I took away: before optimising an algorithm, ask which thread is it running on? Often the fastest fix isn't making the work smaller, it's making sure the user never has to wait for it.

Written by Mohammed Mudassir, senior software engineer in Bengaluru. Turning ideas into scalable products.

say hello

Got an idea that should be a product?

I'm always up for a good product conversation — a role, a collaboration, or just comparing notes on shipping. Open to interesting product conversations.

I usually reply within a couple of days.

Local time
— · Bengaluru, IST
Status
Open to interesting product conversations