Skip to content
Fancy PixelFancy PixelBlog
Back to blog

When Node.js isn't enough: a Rust library to optimize a marine radar

How we profiled an Electron app for a maritime radar, found the heaviest functions with help from AI, and moved the most-used functions to Rust compiled to WebAssembly, without throwing away the UI.

Giovanni Foiani18 min read
Illustration of a circular radar scope in blue and cyan on an ivory background, with a glowing sweep beam, concentric range rings and grid, a few bright target blips, a WebAssembly 'WA' badge and a small Rust crab among the rings

Radar Console is a desktop application that acts as the interface to a marine radar. We rewrote it from scratch, starting from an old Qt codebase that was showing its age, to rebuild it on the stack we use today. It’s an interesting case because it has awkward requirements: on one side a rich, interactive UI, on the other a stream of data that arrives in real time and with no response feedback. This article is about how we tackled a real, not theoretical, performance problem: the strategy we chose and, above all, how we set up the integration of a Rust library (compiled to WebAssembly) meant to become the foundation for moving the heaviest parts of the processing over one piece at a time, without giving up the stack that served us so well right from the start. Spoiler: the first step was building and validating that integration; the big win will come as future work with the heaviest part, the clustering, which is also the hardest to port.

What Radar Console is

Radar Console is an Electron + React + TypeScript app that receives the raw packets from a radar antenna over UDP, decodes them, processes them, and draws them on a PPI scope (the circular image everyone associates with a radar). The whole codebase, both the Main process and the UI, is written in TypeScript, running on the Node.js runtime for the Main process. It follows the Main/Renderer architecture typical of Electron apps, but with a particular focus on high-performance data handling:

  • The Main process (Node.js) hosts the logic modules: DataHandler receives UDP packets, processes them (I/Q extraction, power computation, target clustering) and broadcasts them; Player replays recorded sessions; StatusHandler tracks the radar state. CPU-intensive jobs run in separate workers so they don’t block the main loop.
  • The Renderer process (React) draws the visualization, the maps, and the controls, talking to the Main process via IPC.

The part we care about here is the path each individual packet travels from the moment it arrives from the UDP socket to the moment it becomes a glowing dot on screen. At a low level, radar data is packed inside 16-bit words, and a utility module unpacks it. The live pipeline, simplified, is:

UDP socket → reassambleDataMessage (header) → parseDataMessage (12/14-bit I/Q) → DataHandler → frontend

The problem: a budget about to halve

Today the radar sends us a data block every 5 ms. The hardware roadmap calls for dropping to 2.5 ms. To put it another way: the time we have to receive a packet, reassemble the sweep, extract the I and Q components, compute power, convert it to dB, and hand the result to rendering, all of it, is about to be cut in half.

So why Electron and Node.js? For the reason almost everyone picks them: it’s the most productive tooling we know for building advanced, cross-platform desktop UIs. React for the UI, a single language from frontend to backend, a huge ecosystem, hot reload, near-free distribution on Windows/macOS/Linux. For 90% of the application it’s the right call, and we’d make it again.

The remaining 10% is the problem. JavaScript on V8 is fast, but it has two traits that don’t sit well with the project’s requirements:

  1. The garbage collector (GC). Every sweep allocates arrays of numbers (I, Q, powerData, powerDataDB…). V8 collects them when it wants to, not when it’s convenient for us. A GC pause of a few milliseconds in the wrong place is exactly the kind of jitter you can’t afford when the train leaves every 2.5 ms.
  2. number is a 64-bit float. Part of this code does bit manipulation on integers (& 0x3FFF, << 7, >> 8). In JavaScript every bitwise op implies an under-the-hood float→int32→float conversion. It works, but it isn’t the kind of work it’s tuned for.

We had already squeezed JavaScript where we could. If you read the code:

export function parseDataMessage(packetData: Partial<IRadarRawDataPacket>, wordVector: Uint16Array): IDataMessage {
  const { samplesNumber = constants.totalBins } = Configs.configs.debugParams;
  const { acp = -1, spDataExponent = 0, sweep = -1 } = packetData;

  // OPTIMIZATION: Math.pow once per packet instead of N times.
  const multiplier = 2.0 ** spDataExponent;

  const numSamples = Math.floor(wordVector.length / 3);
  const I = new Array<number>(numSamples);
  const Q = new Array<number>(numSamples);

  let targetIndex = 0;
  for (let index = 0; index < wordVector.length; index += 3) {
    if (targetIndex >= numSamples) break;
    I[targetIndex] = getIcomponent(wordVector, index, multiplier);
    Q[targetIndex] = getQcomponent(wordVector, index, multiplier);
    targetIndex += 1;
  }

  return { acp, sweep, rawData: { I: I.slice(0, samplesNumber), Q: Q.slice(0, samplesNumber) }, exponent: spDataExponent };
}

Pre-allocation, no slice in the loop, a cached multiplier. This is well-written JavaScript. And yet the headroom left wasn’t enough for the jump to 2.5 ms, and squeezing the language any further meant writing increasingly unreadable code for increasingly smaller gains. That’s the moment to switch tools, not to keep sharpening the same one.

Why Rust, and why WebAssembly

The question wasn’t “which language is fastest in absolute terms,” but “what’s the least invasive way to rewrite the most-used functions.” This set of functions is called the hot path: the stretch of code that runs most often, where the program spends most of its time. In our case it’s the chain that processes every single radar sweep, hundreds of times per scan and thousands of times a second. It’s the only place where optimizing actually pays off. And there are actually two answers: which language to write, and how to make it talk to Node.

On the language, Rust won on three fronts.

Predictable performance, no GC. This is the heart of it. Rust has no garbage collector: memory is managed at compile time via ownership. No surprise pauses. For our tight real-time budget, predictability matters more than peak speed: a steady time beats a great time that occasionally spikes because the GC kicked in.

The domain is a perfect fit. This code is arithmetic over numeric arrays plus some bit manipulation: I² + Q² power bin by bin, dB conversions, color-level mapping, clustering over matrices. Rust has native numeric types, zero-cost operations, and iterators the compiler vectorizes. It’s literally the kind of work the language was designed for.

Safety without a runtime. The borrow checker shields us from whole classes of bugs (use-after-free, data races, buffer overflows) that, in software written in a native language, would be the prime suspect the moment the app crashes.

On the how, instead, the choice landed on WebAssembly via wasm-bindgen and wasm-pack, not on a native N-API addon (napi-rs, Neon, C++). The reason comes down to one word: portability. A native addon has to be recompiled for every operating system and every Node/Electron ABI: every Electron upgrade can break the binary and you’re back to the node-gyp/electron-rebuild dance, multiplied by Windows, macOS, and Linux. A WebAssembly module, on the other hand, is a single .wasm artifact that runs identically everywhere, on any platform and any Node/Electron version, with no recompilation and no native toolchain on the machine that builds the app. wasm-pack also generates the JavaScript code that bridges Node and the Wasm module, the so-called glue code (CommonJS, with --target nodejs), and the TypeScript types.

There’s a price, and it’s worth explaining because it comes down to one concrete limit of WebAssembly: the JS↔WASM boundary. JavaScript and WebAssembly live in two separate memories: the WASM module works on its own memory and can’t read JavaScript’s arrays directly. To hand it the input data, and to get the results back, the values have to be copied back and forth across that boundary. On our arrays of hundreds of samples that’s one extra copy per call: small, but not free.

That said, for tight numeric loops native code still has a structural advantage on its side: no garbage collector, so no unpredictable pauses. But it isn’t the zero-cost magic wand it’s sometimes made out to be: the boundary copy is there and has to be accounted for, and how much it weighs against the gain depends on how much work you do inside WASM between one crossing and the next. In our case we accepted the trade mainly for two reasons: more predictable, GC-free performance and, just as important, zero distribution headaches (a single .wasm valid for every platform).

There’s also an upside we haven’t tapped yet but keep in our back pocket: unlike a native addon, which couldn’t even run in the browser, the same WASM module could one day be reused in the React renderer too, rebuilding it with wasm-pack --target bundler instead of --target nodejs, to move heavy computation into the UI without rewriting the utilities. For now it lives only in the Main process, but that door stays open for free.

And above all: we’re not rewriting the application. The idea isn’t to migrate Radar Console to Rust, that would be insane and would throw away everything Electron/React buys us. The idea is very narrow in scope: a small library, compiled to WASM and exposed to Node, that replaces only the most-used and most-expensive functions while keeping their JavaScript-side signatures identical. The rest of the app never notices.

Finding the right functions (with help from AI)

Rewriting the wrong function is the best way to add complexity for zero benefit. Before we started using Rust we needed the answer to a question: where does the time actually go?

The starting point is always the profiler, not intuition. We ran the Main process under the V8 profiler against a recorded session, using Player to replay a real stream at acquisition speed:

node --prof -r ts-node/register simulator.ts
node --prof-process isolate-*.log > profile.txt

The resulting .txt is hard to read: thousands of lines of ticks grouped by function, V8 symbols, interleaving stacks. It comes out looking something like this (simplified excerpt with illustrative numbers):

 [Summary]:
   ticks  total  nonlib   name
   5739   75.6%           JavaScript
    298    3.9%           GC
   1846   24.3%           Shared libraries

 [JavaScript]:
   ticks  total  nonlib   name
   1419   18.7%   27.9%   LazyCompile: *getQcomponent
   1402   18.5%   27.6%   LazyCompile: *getIcomponent
    986   13.0%   19.4%   LazyCompile: *parseDataMessage
    611    8.1%   12.0%   LazyCompile: *bufferToUint16Array
    540    7.1%   10.6%   LazyCompile: *computeRawPower
    388    5.1%    7.6%   LazyCompile: *convertTodBByHandlingZeroValue
    274    3.6%    5.4%   LazyCompile: *getTargetColor
    119    1.6%    2.3%   LazyCompile: *metersByBin

This is where AI saved us hours. Instead of reading the profile by hand, we fed it to the model with a precise request:

“This is the --prof-process output of an Electron app processing radar packets. Group the ticks by functions in our code (ignore Node.js internals and libraries), estimate each one’s share of time, and rank them by cost. For the ones at the top of the list, explain why they’re heavily used and how suitable they are for a native rewrite.”

Then we cross-checked that ranking with a second question, this time about the whole repository: given the pipeline parseDataMessage → computeRawPower → convertTodB → computeColorLevels, which functions are called per bin (not per sweep)? Those are where one microsecond gets multiplied by hundreds of bins across thousands of sweeps per second.

The two analyses converged on the same suspects. The value of AI here wasn’t “discovering” something magic, a profiler read patiently lands on the same conclusions, but compressing the work: reading the graph, correlating it with the real source code, and producing an ordered list with a rationale for each entry. What would have taken an afternoon became a conversation of a few minutes, and we still verified every candidate against the profiler’s raw numbers before believing it.

The cleaned-up ranking:

  1. getIcomponent / getQcomponent, called twice per sample, ~300 bins per sweep. Pure bit manipulation with a branch on the sign: the hottest by far.
  2. parseDataMessage, the loop that orchestrates them and allocates the I/Q arrays each sweep.
  3. bufferToUint16Array, the endianness swap, run on every incoming packet.
  4. computeRawPower, I² + Q² bin by bin, on every sweep.
  5. convertTodBByHandlingZeroValue, one log10 per bin plus zero handling.
  6. getTargetColor, the value→color-level mapping, called for every bin when rendering targets.
  7. metersByBin, a pure geometry helper, small but called constantly.

All of them concentrated in the numeric pipeline, from I/Q component extraction to target drawing, all tight loops over hundreds of bins across thousands of sweeps per second. A perfect target.

The radar-console-utils library

radar-console-utils is a crate of its own (a separate repository, alongside the radar’s), compiled to WebAssembly with wasm-bindgen and packaged by wasm-pack. The code is public on GitHub: github.com/FancyPixel/radar-console-utils, the snippets below are pulled straight from it. The functions are grouped into structs that act as namespaces on the JavaScript side, MathUtils for the power/color computations, DataUtils for packet parsing, so from Node they’re called as static methods (MathUtils.computeRawPowerFast(...)). Cargo.toml is the crate’s manifest file, Rust’s equivalent of a package.json: it declares the package name and version, its dependencies, and how it should be built. In its essential form:

[package]
name = "radar-console-utils"
version = "0.1.1"
edition = "2024"

[lib]
# cdylib: WebAssembly artifact for wasm-pack; rlib: native tests and use as a Rust dependency
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2"

# Profile tuned to shrink the WebAssembly binary
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
strip = true

Let’s look at the translation of one of these functions, computeRawPower, the simplest to show; the I/Q parsing functions in DataUtils (parseDataMessage, getIcomponent/getQcomponent) follow exactly the same pattern. The original JavaScript:

export function computeRawPower(rawData: { I: number[]; Q: number[] }, out?: number[]): number[] {
  const { I, Q } = rawData;
  const { length } = I;
  const powerData = out && out.length >= length ? out : new Array<number>(length);
  for (let i = 0; i < length; i += 1) {
    powerData[i] = I[i] ** 2 + Q[i] ** 2;
  }
  return powerData;
}

And its Rust version, exposed to JS with wasm-bindgen. The I² + Q² loop over a slice is exactly the pattern the compiler auto-vectorizes, and pre-allocation avoids dynamic resizing:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct MathUtils; // struct acting as a namespace

#[wasm_bindgen]
impl MathUtils {
    #[wasm_bindgen(js_name = "computeRawPowerFast")]
    pub fn compute_raw_power_fast(i_data: &[f64], q_data: &[f64]) -> Vec<f64> {
        let len = i_data.len().min(q_data.len());
        let mut power_data = Vec::with_capacity(len);
        for idx in 0..len {
            power_data.push(i_data[idx].powi(2) + q_data[idx].powi(2));
        }
        power_data
    }
}

The #[wasm_bindgen(js_name = "...")] attribute lets us keep idiomatic camelCase naming on the JS side while writing snake_case in Rust. Under the same MathUtils namespace live linear2dB (the dB conversion) and getTargetColor (the value→color mapping), translated with the same pattern. The currently exposed API:

// pkg/radar_console_utils.d.ts (generated by wasm-pack)
export class MathUtils {
  static computeRawPowerFast(i_data: Float64Array, q_data: Float64Array): Float64Array;
  static convertTodBByHandlingZeroValue(power_data: Float64Array): Float64Array;
  static linear2dB(power: number): number;
  static metersByBin(bin: number, miles: number): number;
  static getTargetColor(levels: Float64Array, value: number, min_visible_color_level: number): number;
}
export class DataUtils {
  static parseDataMessage(word_vector: Uint16Array, sp_data_exponent: number, samples_number: number): ParsedData;
  static getIcomponent(data: Uint16Array, offset: number, multiplier: number): number;
  static getQcomponent(data: Uint16Array, offset: number, multiplier: number): number;
  static bufferToUint16Array(bytes: Uint8Array): Uint16Array;
}
export class ParsedData { i: Float64Array; q: Float64Array; }
export function getApiVersion(): string;

Worth noting one detail that has an impact at runtime: &[u16]/&[f64] parameters on the Rust side become Uint16Array/Float64Array on the JS side. Passing a plain number[] won’t work, the data has to be converted into a typed array, and that’s exactly the boundary copy we talked about above. The packet-parsing functions (parseDataMessage, getIcomponent/getQcomponent, bufferToUint16Array) live in the DataUtils namespace; the power/color computations in MathUtils, mirroring the original split between packet parsing and signal computations.

For the build we use wasm-pack, which compiles the crate to the wasm32-unknown-unknown target, optimizes the binary, and generates the Node-usable package. The target name already tells you its purpose: wasm32 is the 32-bit WebAssembly architecture, while the two unknown fields stand for unknown vendor and unknown operating system, that is, none. That’s exactly what we want: a binary that makes no assumptions about the host environment (no OS, no system libraries) and therefore runs identically everywhere, in Node as well as the browser. In practice we avoid a platform-specific target in favor of “pure” WebAssembly, which is what buys us the portability. The one-time prerequisites:

# WebAssembly compilation target
rustup target add wasm32-unknown-unknown

# wasm-pack
cargo install wasm-pack

The key flag is --target nodejs, which generates glue code based on require/module.exports (CommonJS) rather than for bundlers or the browser:

# Production build (optimized)
wasm-pack build --target nodejs --release

# Or a development build (faster, unoptimized)
wasm-pack build --target nodejs --dev

The output lands in the pkg/ folder: the radar_console_utils_bg.wasm binary, the radar_console_utils.js glue, the radar_console_utils.d.ts types, and a ready-made package.json. A single .wasm, valid for all platforms: no separate builds for Windows/macOS/Linux, which is exactly the reason that made us prefer WASM over a native addon.

For the Main process to resolve the import, the library has to be declared among the radar project’s dependencies. wasm-pack generates a package.json inside pkg/ with main (radar_console_utils.js) and types already configured: so we just point exactly there. Since radar-console-utils is a separate repository next to the radar’s, we use a local dependency with the file: prefix pointing at the generated pkg/ folder:

{
  "dependencies": {
    "radar-console-utils": "file:../radar-console-utils/pkg"
  }
}

After a yarn install (or npm install) the library is linked into node_modules/radar-console-utils and require('radar-console-utils') finds it like any other package, with a single .wasm in tow, without the platform-specific optionalDependencies a native addon would need. If we ever published it to an npm registry (even a private one), the only change would be swapping the local path for a name and a version:

{
  "dependencies": {
    "radar-console-utils": "^1.0.0"
  }
}

The code doesn’t change: the import/require stays identical, only where npm/yarn resolves the package changes.

And the performance? Here we have to tell the truth: on these functions, taken one by one, we don’t have a convincing net gain yet, and we partly expected that. They’re small, regular loops, exactly the kind of code V8’s JIT already optimizes very well; and the native advantage is eroded by the boundary, because copying the arrays in and Array.from on the way back add work and allocations right where we wanted to remove them. The computation itself is faster, but on scans of a few hundred bins the transfer cost can eat the margin. What this first pass gave us isn’t a number to show off: it’s a Rust→WASM integration that runs end-to-end and produces results identical to the JavaScript (we check it with an equivalence test, below). And that’s exactly the foundation we needed for the next step.

Swapping the calls

With the library linked, the change in the code is surprisingly simple: import MathUtils from radar-console-utils and replace the JS function calls with the corresponding static methods. Where computeRawPower used to be imported from the signal utilities, MathUtils now comes in:

- import { computeRawPower, convertTodBByHandlingZeroValue, /* … */ } from './signal';
+ import { convertTodBByHandlingZeroValue, /* … */ } from './signal';
+ import { MathUtils } from 'radar-console-utils';

At the point where the function is called only the invocation changes, and here’s where we pay the overhead of using WASM: arrays have to be converted into a Float64Array going in, and the returned Float64Array has to be brought back to a number[] with Array.from where the rest of the code expects a plain array:

- const realRawData = computeRawPower(rawData);
+ const realRawData = Array.from(MathUtils.computeRawPowerFast(dataI, dataQ));

The same goes for the other functions moved over:

const dB = MathUtils.linear2dB(value);
const meters = MathUtils.metersByBin(i, miles);
const newLevel = MathUtils.getTargetColor(new Float64Array(rangeLevels), dBValue, minVisibleColorLevel);

No changes to the rest of the pipeline, and, worth stressing, no changes to the webpack configuration: the package generated by wasm-pack --target nodejs is standard CommonJS, and webpack 5 (target electron-main) resolves it like any other dependency in node_modules, .wasm included. The only runtime cost is the data copy at the boundary.

This is the “direct” integration we used to validate correctness and speed. Before taking it to production, the natural hardening step is to wrap it behind a small wrapper with an automatic fallback to JavaScript and a flag, so we can turn the WASM path off at runtime if some unexpected behavior surfaced at sea:

// wrapper with automatic fallback (later hardening)
import * as jsImpl from './signal';
import Configs from './config';

let wasm: typeof import('radar-console-utils') | null = null;
try { wasm = require('radar-console-utils'); } catch { /* fall back to JS */ }

export function computeRawPower(rawData: { I: number[]; Q: number[] }): number[] {
  if (wasm && Configs.configs.debugParams.useNativeMath) {
    return Array.from(wasm.MathUtils.computeRawPowerFast(
      Float64Array.from(rawData.I), Float64Array.from(rawData.Q),
    ));
  }
  return jsImpl.computeRawPower(rawData);
}

The last piece is the correctness comparison, not just the speed one: a test that runs both implementations against the same input and checks that the results match. A fast-but-wrong rewrite is far worse than slow-but-correct JavaScript.

it('WASM and JS produce the same output', () => {
  const rawData = { I: [1, 2, 3], Q: [4, 5, 6] };
  const js = jsImpl.computeRawPower(rawData);
  const rs = Array.from(MathUtils.computeRawPowerFast(
    Float64Array.from(rawData.I), Float64Array.from(rawData.Q),
  ));
  expect(rs).toEqual(js); // [17, 29, 45]
});

What we learned

The takeaway isn’t “Rust beats JavaScript”, that would be a buzzword headline and, in our case, not even proven. The takeaway is that picking the right tool is a local decision, not a global one. Electron, React and TypeScript remain the correct choice for 90% of Radar Console: the UI, the controls, cross-platform distribution. For the 10% that lives in a tight loop, GC-free compiled code can be the right instrument, and running it as WebAssembly lets us try it without inheriting the burden of shipping a different native binary for every platform and Electron version.

The concrete result of this first pass, then, isn’t a benchmark chart: it’s having built and validated the integration. Today we have a Rust library compiled to WebAssembly, wired into the app without touching webpack, with a clear boundary and a test that guarantees the native code produces the same numbers as the JavaScript. On the small functions the gain is marginal and still to be measured, and that’s fine: they were the proving ground for the pipeline, not the final prize.

A couple of closing thoughts. Adding Rust and WASM to the project has a cost: one more toolchain in the build pipeline, the JS↔WASM boundary with its copies to keep an eye on, and a skill not everyone on the team has mastered to the same level yet. It’s a cost worth paying only when the profiler, not enthusiasm, says it’s worth it. And AI, through all of this, was an analysis accelerator, not an oracle: it helped us read the profiles and translate the first draft of code, but the decision about what to rewrite, and the verification that the result was correct, rightly stayed human work.

The real prize is the next step, and it’s also the hardest: porting the clustering, the anti-clutter (clutter suppression). It’s by far the bulkiest and heaviest part of the processing, and we haven’t ported it yet: it’s at the top of our TODO list. It’s no longer simple arithmetic over arrays, but matrices, data structures and algorithms with lots of allocations and branching. And that terrain, not the small numeric loops, is exactly where a GC-free language with controlled memory can make a real difference, also because you cross the boundary once instead of on every bin. That’s where this integration, validated for now, will have to prove it’s worth the numbers. We’ll tell that story, benchmarks in hand, once we get there. Stay tuned.

Acknowledgments

Thanks to Eleonora Torrisi for the constant support and for the fruit candies.