ionTrails Modernization
Engineering

One Rust Core, Multiple Platforms: What WebAssembly Makes Possible

Published on November 1, 2026 by Viliam Simko

Software often grows in several directions at once. A tool might start as a desktop or command-line program, then need an R integration, a Node.js interface, and eventually a version that runs directly in a web browser.

One way to handle that growth is to rewrite the same important logic for every environment. The downside is familiar: more code to maintain, more places for bugs to appear, and a risk that different versions slowly behave differently.

In corrplot-ng, I have been exploring another approach: keep the reusable logic in Rust and connect it to different environments through small, platform-specific interfaces.

This article looks at what that makes possible, what it costs to bring Rust into the browser, and what measurements from the project have taught me so far.

One shared Rust core, several integration paths

Rust shared core Matrix validation · Typed-scene SVG and RGBA rendering
Rust CLIImplementedUses shared core for matrix validation
Node.jsPartial bindingNative binding for selected core operations
BrowserWASM integrationValidation and rendering of prepared scenes
R / rextendrEvaluation adapterPrototype; not the production R package

The integrations expose different subsets of functionality. The complete matrix-to-scene pipeline and production R migration are not yet complete.

One core, different ways to use it

The basic idea is simple: put the work that should behave consistently in one shared Rust core. Then let each environment communicate with that core in the way that makes sense for it.

The integration paths are at different stages:

The shared crate is real, but its scope is still deliberately limited. It provides matrix validation and typed-scene SVG/RGBA rendering; it does not yet build the complete scene from an input matrix. The integrations therefore expose different subsets of functionality.

This is still useful: the operations that have moved into the shared crate can be implemented and tested once. It would be premature, though, to say that every host already uses the same complete visualization engine.

What WebAssembly adds

WebAssembly (often shortened to WASM) is a format that lets code compiled from languages such as Rust run in environments that support it, including modern web browsers.

That means a browser-based application can call Rust code compiled to WebAssembly. Where the binding exposes a shared-core operation, there is no need to reimplement that operation in JavaScript.

In corrplot-ng, the current browser-facing module can validate matrix data and render an already prepared scene as either SVG or pixel data. The complete pipeline that turns an input matrix into that scene is still being developed, so this is an important but deliberately limited milestone.

There is also a practical question: how much does this browser capability cost?

The numbers: a small module, with measurable startup

A WebAssembly file is not necessarily transferred in its raw form. Websites commonly compress assets, so it is useful to look at both the original module and its compressed size.

The project’s first reproducible baseline measured:

50.9 kB Compressed WASM Brotli, from a 142.7 kB module
30 ms Cold initialization Measured in Node.js CI
−66% Linux native library Approximate reduction after optional build-time stripping
3 Native platforms Linux, macOS and Windows tested

The compressed module is therefore about 51 kB with Brotli in this measurement. That is a useful reference point for thinking about delivery size, although the actual transfer and startup experience will depend on the application, browser, device, and network.

The same test run also measured representative operations. Matrix validation and SVG generation were very quick for the test data; rendering pixel output took longer because it processes the image’s pixels. These results are recorded as a baseline, not as a promise of identical performance on every machine.

Why measure this at all? Because “it runs in the browser” is only the beginning. A useful engineering decision also asks how much code must be downloaded, how long initialization takes, and how expensive the work is once the module is running.

The measurements were collected in a documented Node.js CI environment. The project records the test setup so future changes can be compared under the same conditions. See the WASM baseline PR.

Native library and R package sizes across platforms

The first native-size experiment measured an optional build configuration that asks the native linker to remove debugging information. The historical Linux library was about 6.21 MB before that optimization and about 2.10 MB afterward—a reduction of roughly 66%. That was a comparison between two builds of the earlier generated R package, not a direct comparison with the current native adapter.

A later controlled CI workflow measured the legacy generated R package and the current native extendr library side by side on Linux, Intel macOS, and Windows. It records four distinct artifacts:

Platform Legacy installed R package Legacy installed native library Current native extendr cdylib Legacy R source tarball
Linux x86-64 2,112,721 B (2.0148 MiB) 2,100,280 B (2.0030 MiB) 787,392 B (0.7509 MiB) 1,049,565 B (1.0009 MiB)
Intel macOS x86-64 1,746,241 B (1.6653 MiB) 1,733,792 B (1.6535 MiB) 664,392 B (0.6336 MiB) 1,048,270 B (0.9997 MiB)
Windows x86-64 1,176,796 B (1.1223 MiB) 1,164,288 B (1.1104 MiB) 2,010,425 B (1.9173 MiB) 1,041,061 B (0.9928 MiB)

The current native cdylib is smaller than the legacy installed native library on Linux and Intel macOS in this run, but larger on Windows. Linux and macOS use matching linker debug-stripping settings for the current native build; Windows is left unstripped, consistent with the legacy generator’s Windows behavior. These figures should not be read as a platform-only comparison of compiler efficiency.

There are important limits to the comparison:

The workflow stores the size report, toolchain versions, target triple, linker settings, dependency tree, and current native Cargo.lock with each platform artifact. All three runs used Rust 1.99.1, Cargo 1.99.1, and R 4.6.2. The targets were x86_64-unknown-linux-gnu, x86_64-apple-darwin, and x86_64-pc-windows-gnu. The legacy generated package used extendr 0.9.0; the current native crate declares extendr 0.8 as its Cargo semver requirement and resolves to extendr-api 0.8.2 in the recorded lockfile. For the current native cdylib, CI passed -C link-arg=-Wl,--strip-debug on Linux and -C link-arg=-Wl,-S on macOS; Windows had no corresponding strip flag. The workflow’s CORRPLOT_REXTENDR_STRIP_DEBUG=true setting also records the legacy generated-package build configuration. See the controlled binary-size experiment and its corrected successful three-platform CI run.

What the measurements do—and don’t—tell us

A few practical lessons have emerged:

  1. Measure what people actually receive. A native library, an installed package, a source archive, and a compressed WASM file are different artifacts. Naming the artifact makes a size comparison meaningful.
  2. Optimize the right thing. The native experiment showed that build-time linker settings mattered much more than an additional cleanup pass.
  3. Make boundaries explicit. When Rust returns rendered pixels to JavaScript, the current binding copies them into JavaScript-owned memory. That makes memory ownership predictable, though the copy itself has a cost.
  4. Be precise about browser support. The module can load and render in a browser, and its output can be displayed using a regular Canvas 2D context. That does not mean it already includes a multithreaded renderer, a worker-management system, or OffscreenCanvas support. The current WASM build is single-threaded.
  5. Keep a repeatable baseline. Measurements are most useful when the runtime, test data, and method are recorded. That lets us spot changes without pretending one benchmark represents every device.

Follow-up: choosing an R-to-Rust boundary

The package-size experiment above measured an early native rextendr adapter and a WASM module, but it did not compare the cost of the different ways R can host that module. A later experiment puts V8 + WASM, Wasmtime + WASM, and native Rust + extendr side by side using the same Rust validation core. The results and caveats are in WASM It Worth It? Calling Rust from R.

The bigger idea

WebAssembly is interesting not just because it lets Rust run in a browser. In this project, it is one way to expose selected operations from a shared Rust crate to another environment. The broader goal is to grow that shared surface carefully, while keeping each platform adapter small and testing the behavior it actually exposes.

There is still work ahead. The project needs to complete more of the matrix-to-scene pipeline, broaden browser coverage, and get further guidance on native package distribution. But the direction is clear: share the logic where it makes sense, keep the platform-specific layers focused, and measure the real cost of each integration.

That is how “write it once” becomes more than an architectural slogan.


The figures above come from specific project CI runs and test fixtures. They are engineering baselines, not general performance guarantees for Rust, WebAssembly, or native packages.

← Back to all articles