24 Sep 2026 · Engineering · 7 min read

A Million Points, One Cursor: Making Vibration Analysis Fast in the Browser

When an analyst drags a harmonic cursor across a vibration spectrum, the cursor should move with the mouse without lag. With over a million data points on screen, ours didn’t. Here’s how we fixed that, and what other teams can take from it.

Context

We built a unified condition-based monitoring (CBM) platform for one of our clients, which brings sensors from different OEMs onto one screen. Most factories don’t buy all their sensors from one vendor, so this matters. It also means the data varies a lot. Some sensors sample slowly and some very fast. Some send short captures and some send long ones.

For each reading, analysts look at two charts: the TWF (time waveform, the raw signal over time) and the FFT (the same signal broken into frequencies). They place harmonic and sideband cursors on the FFT to match peaks to machine faults.

The problem: over a million points

We show the TWF and FFT for all three measurement axes together: Axial (A), Vertical (V) and Horizontal (H).

For example, imagine a sensor sampling at 24 kHz over a 10-second window:

  • TWF: 24,000 × 10 = 240,000 points per axis
  • FFT: N samples give N/2 frequency bins, so 120,000 points per axis (0.1 Hz resolution, up to 12 kHz)
  • Across all three axes: 720,000 TWF points + 360,000 FFT points ≈ 1.08 million points on screen

Why not just downsample? We tried. ECharts, our charting library, has a built-in sampling option, and we set it to LTTB, a popular downsampling algorithm for charts. But the interactive tools, like cursors, hover and markers, started behaving inconsistently. The likely reason: with sampling on, the drawn line only goes through the points LTTB picked, and those picks change as you zoom. Our hover and markers still snapped to the real FFT bins, so what was drawn and what the tools pointed at could drift apart. Earlier experience had also shown us that downsampled data from some OEMs was missing important peaks, and for an analyst, a missing peak can mean a missed fault. So we chose to show every point as received.

The result: marking harmonics on this much data was slow and laggy.

TL;DR: Memoization, moving compute off the main thread with Web Workers (transferring buffers instead of cloning them), per-frame chart updates without React, binary search instead of linear scans, and virtualized lists.

The budget: 10 milliseconds

Before fixing anything, we set a target. A 60 Hz display shows a new frame every 16.6 ms, but your code doesn’t get all of it. Google’s RAIL performance model recommends finishing your own work in about 10 ms, because the browser needs the remaining ~6 ms to draw the frame. On a 120 Hz display, the whole frame is only 8.3 ms.

Our goal: once the data has loaded, no loading screens, no stutter, no lag.

The “once loaded” part matters because the flow spans three screens: Point Overview → Spectrums Overview → Analysis. We load the data once and serve it from a cache for every later screen, annotation and report. So after the first load, navigating feels instant.

Fix 1: Get the heavy work off the main thread

The flame graph (a profiler view of where time goes) showed most of the time going into calculations. We derive a lot from the raw TWF on the frontend, such as unit conversions.

Here’s the core issue. In the browser, JavaScript runs on the main thread by default, and that same thread handles layout, painting and user input. While our math ran, the page couldn’t respond: stutter, dropped frames, a cursor that lagged behind the mouse.

So we moved the calculations to Web Workers, a browser API for running code on background threads.

There’s a catch. Sending data to a worker with postMessage copies it by default (the browser structured-clones it), and cloning hundreds of thousands of numbers can itself block the main thread. So for large payloads, we do one copy ourselves: the main thread packs the fetched numbers into Float64Array s, each a compact block of raw memory, and then transfers their underlying buffers to the worker instead of cloning them:

const twf = Float64Array.from(response.twf);
const fft = Float64Array.from(response.fft);
worker.postMessage({ twf, fft }, [twf.buffer, fft.buffer]);

A transfer doesn’t copy the bytes. It moves ownership of the memory to the worker, and the main thread’s copy becomes unusable (detached). When the worker is done, it transfers the parsed values and axes back the same way. This approach isn’t completely free, because we still pay for that one copy into the typed arrays, plus normal messaging overhead. But the large data never gets cloned across the thread boundary in either direction.

For small payloads (under 131,072 values), we skip all this and let postMessage clone the data. At that size the copy is cheap, and the extra step isn’t worth it.

One limit to keep in mind: workers move the computation off the main thread, not the rendering. Workers can’t touch the page directly, so in our setup ECharts still draws the chart on the main thread.

Lesson: Keep the main thread free for the user. Move heavy math to a worker, and transfer large buffers instead of copying them. Only add that complexity above a size where you’ve measured it pays off.

Fix 2: Stop rebuilding the chart on every mouse move

The cursor still lagged behind the hover grid lines on large spectrums. The cause: every mouse move rebuilt the chart’s entire configuration object, including:

  • FFT x-axis data
  • marker lines
  • tooltip configuration
  • zoom configuration
  • all the styling

Even an unrelated state change created a new, large configuration and made ECharts process all of it again. (Hello re-rendering, my old friend.)

Memoize the expensive parts. We wrapped the chart options in React’s useMemo so they aren’t rebuilt on every interaction. We also memoized the large FFT series data separately. It depends only on the x-axis and the spectrum values, so an unrelated state change can no longer create a new copy of a 120,000-point array.

Skip React for per-frame updates. For interactions that fire on every mouse move, we stopped going through React entirely:

  • Normal crosshair: handled by ECharts’ built-in axisPointer . ECharts draws it itself, with no configuration rebuild at all.
  • Marker hover preview: while the user moves a harmonic or sideband marker, we update only the marker lines, directly on the chart instance: chartInstance.setOption({ series: [{ markLine: ... }] }). Because we don’t pass notMerge, setOption runs in merge mode and changes just the marker lines, leaving the big FFT series alone. We throttle these updates with requestAnimationFrame , so there is at most one update per frame.
  • Graphic overlay: persistent tooltip labels and the zoom selection are drawn with ECharts’ graphic component.

Lesson: Anything that happens on every mouse move should go straight to the chart instance. Keep React for state that changes rarely.

Fix 3: Find the right frequency faster

Harmonic, sideband and marker tools constantly need the FFT bin closest to a requested frequency.

FFT x-axis: [0, 10, 20, 30]
Target: 18
Closest bin: 20

Our old getClosestxAxisValueToBaseFrequency() used reduce and compared the target against every value. That is O(n): for a 120,000-point FFT, up to 120,000 comparisons per lookup, repeated for every marker. But the x-axis is sorted, so the classic CS 101 answer applies: binary search. That makes each lookup O(log n), which is about 17 comparisons instead of 120,000.

Bonus: FFT bins are evenly spaced (Δf = sampling rate ÷ N), so you can even compute the closest bin directly with Math.round(target / Δf). That’s O(1), constant time.

Lesson: Before you loop, ask what you know about your data. Sorted data allows binary search. Evenly spaced data allows direct calculation.

Fix 4: Only render what’s visible

The FFT view has a compare feature: the analyst can superimpose up to five previous recordings onto the current spectrum to see how the machine’s vibration has changed over time. To choose which recordings to overlay, they pick from a checklist of every past recording for that sensor. Some sensors had a very long history, so this list rendered 2,551 checkboxes, even though only about ten fit on screen at once.

We switched the list to virtualization: it only mounts the rows the user can currently see and swaps them in as the user scrolls.

Lesson: Users can only see about a screen’s worth of rows, so the DOM should only hold about that many.

The results

We audited the UI before and after these changes. “Compare panel load” is opening the compare checklist, and “Select three overlays” is picking three past recordings to superimpose on the current FFT. All times are in milliseconds.

Loading and interaction

Scenario Before After Change
Compare panel load4,638 ms58.4 ms−98.7%
Select three overlays24,084 ms352.1 ms−98.5%
React renders, select three overlays653,60310,578−98.4%
React renders, compare panel options58,9852,248−96.2%

Main thread (from the performance trace)

Scenario Metric Before After Change
Compare panel loadLongest task5,725.7 ms254.8 ms−95.5%
Select three overlaysLongest task7,684.1 ms279.4 ms−96.4%
Select three overlaysTotal task time32,191.7 ms1,756.1 ms−94.5%

DOM and memory

Metric Before After Change
DOM nodes17,1751,919−88.8%
Mounted checkboxes2,55110−99.6%
Used JS heap211.8 MB126.2 MB−40.4%

The “longest task” row is the one to notice. Before, the main thread was sometimes blocked for over 7 seconds straight, and the page was frozen for that whole time. Now the longest block is under 300 ms, and it only happens while opening the compare panel or selecting overlays, not while the analyst is moving cursors.

What’s left

Not everything is at 10 ms, and it’s worth saying so. Loading still has tasks around 250 ms, over a million points still have to be drawn, and one path isn’t optimized yet. When a harmonic marker is committed (placed, not just previewed), we still rebuild the full chart configuration and pass it through React. The numbers show it: harmonic-commit renders only dropped 8.4% (3,855 → 3,530), and main-thread function time dropped 23.7%. The next step is to handle committed markers the same way as the hover preview, with a merge-mode update of only the marker data.

Here’s how it feels now: Point Overview → Spectrums Overview → Analysis, with over a million points and no loading screens after the first load.

Lessons

  • Measure first. The flame graph pointed us to the real cost.
  • Protect the main thread. Computation belongs in workers. Large data should be transferred, not copied.
  • Don’t route high-frequency updates through your framework. Mouse-move updates should go straight to the chart.
  • Use what you know about the data. Sorted data allows binary search. Evenly spaced data allows O(1) lookups.
  • Render only what’s visible.
  • Don’t trade correctness for speed. Downsampling would have been easier, but for diagnostics, a missing peak is a missed fault.

Book a call

Tell us about the process or problem. We reply within one working day to set up the call.

Solutions

Company

Contact

sales@hopnet.co.in

+91 79811 49871

T-Hub Phase 2, 20, Inorbit Mall Road, Vittal Rao Nagar, Madhapur, Hyderabad, Telangana 500081

Social

© 2026 Hopnet Communications LLP · LLPIN: AAX-8476