Skip to main content
Browse the docs+

Frontend Jank: Give Service Workers, Web Workers, and React the Right Jobs

A practical guide to reducing repeat I/O with a Service Worker, moving measured CPU work into Web Workers, and isolating React renders before they reach expensive charts.

A smaller client can still feel sticky.

The JavaScript may arrive quickly, yet dragging across a chart drops frames. Typing into a dialog may cause an unrelated chart to render again. Returning to a tab may repeat storage, decryption, and data preparation that the browser has already completed.

They all look like “the page is lagging,” but they are different problems.

At 60 Hz, the browser has roughly 16.7 milliseconds to prepare a frame. JavaScript, React commits, layout, painting, and input handling all share that time on the main thread. A task that occupies it for tens or hundreds of milliseconds will make the interface feel stuck, regardless of how small the download was.

Our recent Market Detail work led to one useful rule: find what is occupying the main thread, then give repeat I/O, heavy computation, and UI updates separate owners.

Similar symptoms need different boundaries

Adding memo everywhere is not a diagnosis. Neither is sending every function to a Worker.

Source of delayBetter boundaryIntended result
Repeated static-resource or history readsService WorkerReuse successful downloads and disposable browser data
Indicator formulas and replay calculationsWeb WorkerKeep measured CPU-heavy work off the UI thread
Unrelated state waking large componentsStore selectors and React leavesLimit commits and native-widget synchronization
A one-row realtime change rebuilding all historyIncremental chart updateMake small changes remain small

Service Workers and Web Workers are especially easy to confuse. Their names are similar, but their jobs are not.

A Service Worker sits between a web application and resource delivery. It is useful for caching, offline recovery, and background resource handling. A Web Worker is a separate computation context for work that does not need direct DOM access.

The distinction matters. A Service Worker is not the right home for calculating indicators across 24,000 candles.

Use a Service Worker to remove repeat I/O

Our Service Worker keeps a cache-first cohort for content-hashed JavaScript, CSS, fonts, and Worker files. If the bytes do not change, the URL remains reusable. A lazy route or interaction module can hit that cache after its first successful load instead of downloading the same file again after every application release.

Public images have their own cache policy. A response must match an approved public boundary, return a valid image type, and avoid private or credential-varying headers before it can enter Cache Storage. Authenticated HTML, business Actions, private attachments, and user data are excluded.

Market history needs a different design. Closed candles can be encrypted and stored in IndexedDB. The Service Worker receives bounded requests over a MessageChannel, performs the storage and crypto work, and returns a disposable browser projection. The server remains authoritative.

The cache is deliberately optional:

  • the newest page still comes from the server;
  • first content does not wait for Service Worker installation or activation;
  • unsupported Workers, unavailable IndexedDB, key bootstrap errors, or a two-second channel timeout bypass the cache;
  • cancelled or destroyed page work cannot accept a late cache response.

This is a more important property than the cache-hit path. An optimization should make the normal path cheaper, not become a new condition for correctness.

A Service Worker reduces repeat I/O. It does not erase the cold-start bundle cost, and it does not prove that a first visit is faster. Browser storage pressure or a user clearing site data can remove the cache at any time.

Put measured CPU work in a Web Worker

Technical-indicator calculation is a much better Web Worker candidate because it is pure CPU work.

In our reproducible synthetic inventory, 23 indicator types across 2,400 candles took about 30 milliseconds at the median. Across 24,000 candles, the median was about 323 milliseconds, with the slowest of seven measured samples at roughly 559 milliseconds. These are CPU timings, not production interaction latency, but they clearly exceed work we want competing with input and painting.

Moving the formula function into a Worker is only the start. A user can switch timeframes, change parameters, or prepend history faster than a calculation finishes. Processing every intermediate request would create a perfectly asynchronous backlog—and the result would still feel late.

Our controller allows one active calculation and retains only the newest pending one. Each submission gets an increasing generation number. A result reaches React only if its generation is still current and the owning component is still mounted.

That gives the boundary three useful properties:

  1. an old timeframe cannot overwrite a newer selection;
  2. rapid changes do not build an unbounded calculation queue;
  3. unmounting terminates the Worker and discards pending work.

The page context stays on the main thread, so a result does not clone the complete candle array back to the window. The formula fingerprint also excludes appearance. Changing a color or line style reuses the existing calculation. Switching locale does not recreate the Worker, submit formulas again, or call full candle setData.

Web Workers are not free. postMessage still serializes or clones values, and a Worker has lifecycle cost. Moving a sub-millisecond function away from the main thread may cost more than running it. Bounded, independent work measured in tens or hundreds of milliseconds is a much stronger candidate.

WASM deserves the same restraint. It is an execution format, not a magic performance flag. We only consider it after the remaining algorithm cost and numerical equivalence are measured.

Narrow subscriptions before adding memoization

Many React performance problems begin before React renders anything: a component subscribed to more state than it needs.

When a page reads an entire Store, one character typed into a dialog can wake the chart, side panel, and document workspace. Wrapping every child in memo tries to contain the damage after the update has already reached the wrong boundary.

We first moved subscriptions to the leaves that own the data. A single field uses a single selector. Small groups use shallow comparison. The chart, alert dialog, indicator dialog, Review panel, and document body each observe their own slice. Expensive leaves use memo, but only after their array, object, and callback props have stable identities.

Several tiny details make that work:

  • default empty collections are shared constants instead of a new [] on each render;
  • derived inputs change only when their semantic dependencies change;
  • effect-owned callbacks can see current committed props without tearing down and rebuilding the resource;
  • ordinary callbacks use useCallback only when identity matters to a child or subscription.

We verify this with mounted React, real Zustand subscriptions, the real chart, and the indicator Worker—not by reading the source and deciding that it looks optimized.

InteractionExpected leafExpensive boundary that should stay quietObserved result
20 dialog draft updatesDialogPage shell and candle chart0 shell renders, 0 chart renders
100 vertical cursor updatesCursor valueHistogram100 leaf renders, 0 histogram renders
20 document-body editsBodyDocument workspace shell20 body renders, 0 shell renders
Locale switchTextWorker and candle dataset0 Worker restarts, 0 formula submissions, 0 full setData calls

These numbers prove subscription isolation in the test boundary. They are not a promise about latency on every device. That narrower claim is still far more useful than “we added memo, so it should be faster.”

Make a small data change stay small

React may remain quiet while a native chart still performs too much work internally.

An earlier chart path combined multiple renderers and created work that grew with candle history. Moving the technical chart to one Canvas-based chart with native panes removed per-candle DOM and SVG crosshair nodes. With 2,000 synthetic rows, the chart retained about 142 fixed DOM nodes and 27 canvases. The node count no longer grew with the number of candles.

Realtime data usually replaces the open candle or appends one new candle. It should not rebuild the complete timeline. The incremental path calls update only on the candle and affected indicator series. In the 2,000-row fixture, 17 series updates took about 1.2 milliseconds, caused no full setData, and did not increase DOM size.

Prepending older history is genuinely structural, so it can use a full data update. The chart then restores the logical viewport so the candles under the user’s eyes do not jump. Appearance-only edits preserve existing series and panes where the chart API permits it.

By this point, memo is only the last small gate. Most of the saving came from earlier choices: do not model each data point as a DOM node, do not send a local state change through the whole page, and do not turn a one-row change into a full-history replacement.

Keep three separate performance ledgers

We no longer use one timing number to describe every kind of frontend performance.

The first ledger is CPU work. Fixed input sizes, one warm-up, and a stated sample count produce median and slower-sample timings. This tells us which computations can remain on the main thread and which deserve a Worker.

The second is render work. A browser harness counts React renders, Worker submissions, chart setData, and incremental updates. It catches a regression where the visible result is correct but an expensive boundary was touched again.

The third is interaction evidence. Browser Long Tasks, input response, scrolling, dragging, errors, viewport stability, and cleanup reveal costs that a pure function benchmark cannot see.

These ledgers should stay separate. A faster Node function is not measured production latency. Fewer React renders do not automatically prove a smooth gesture. A cache contract can prove safe reuse without claiming a network speedup that was never timed.

The result is not “more Workers”

There is no universal switch for frontend jank.

The Service Worker removes repeat resource work while remaining optional. The Web Worker isolates measured computations and accepts only current results. React keeps state changes inside the leaves that own them. Charts and editors still need bounded DOM and incremental updates inside those leaves.

The useful acceptance questions are concrete:

  • Did the longest main-thread task get shorter?
  • Does a local input still wake the whole page?
  • Can obsolete background work continue to queue or commit?
  • Does the page remain correct when the cache is unavailable?
  • After the same action happens 20 or 100 times, how often did the expensive component actually render?

A smooth page is not the one with the most Workers. It is the one where every piece of work has the right owner—and the main thread has enough time left to draw the next frame.

END / KEEP BUILDING