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
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:
- Rust CLI: implemented and uses the shared crate for matrix validation.
- Node.js: an initial native binding exposes selected operations, including matrix validation and typed-scene rendering.
- Browser: the WebAssembly binding exposes matrix validation and rendering of an already prepared scene.
- R: a disposable rextendr evaluation adapter proves that R can call the same compiled crate. It is not part of the production R package.
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:
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 legacy generated package uses extendr 0.9.0, while the current native crate declares extendr 0.8 as its Cargo semver requirement. Their dependency graphs are not assumed to be identical.
- The current native adapter is a standalone cdylib experiment, not yet a separately installable R package. There is therefore no corresponding installed-package or source-tarball measurement for it.
- The source archive shown above belongs to the legacy R package; it is not the source archive for the current native adapter.
- These are exact artifact sizes from one successful CI matrix run, not end-user download sizes or universal build-size guarantees.
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:
- 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.
- Optimize the right thing. The native experiment showed that build-time linker settings mattered much more than an additional cleanup pass.
- 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.
- 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.
- 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.