ionTrails Modernization
Performance Engineering

WASM It Worth It? Calling Rust from R

Published on November 1, 2026 by Viliam Simko

Calling Rust code from R introduces a choice: should we use a WebAssembly runtime, embed a JavaScript engine, or load a native Rust library? Each option has a different balance of portability, performance, and runtime footprint.

To make that trade-off concrete, this experiment compares three ways of exposing the same Rust function to R: V8 + WebAssembly, Wasmtime + WebAssembly, and native Rust via extendr. The shared function comes from a small component of corrplot-ng, but the focus here is on the integration approaches—not the application itself.

WebAssembly is often presented as the universal escape hatch: compile your code once, run it almost anywhere, and keep the platform-specific glue to a minimum. But does that portability come at a measurable cost?

So, WASM it worth it?

I built a small experiment around the same Rust function and compared all three approaches. The short answer: WebAssembly works, but in this particular R integration native Rust is faster and much smaller. Wasmtime is a credible middle ground; V8 pays a noticeably higher per-call cost in this prototype.

These are engineering measurements for one workload and integration setup, not a universal ranking of runtimes.

R calls the same Rust function through three different integration paths

V8 + WebAssembly

Embedded JavaScript engine
Loads and runs the shared WASM module

Wasmtime + WebAssembly

Standalone WASM runtime
Loads and runs the shared module

Native Rust + extendr

Direct native binding
No WebAssembly runtime

Shared Rust core Same implementation · Same contract · Comparable work
PerformanceHow much overhead does each integration path add?
Runtime footprintWhat needs to be installed and distributed?
PortabilityWhat does it take to ship across platforms?

Three roads from R to Rust

All three approaches expose the same Rust function, corrplot_core::validate_matrix, to R. Using one implementation and identical JSON fixtures keeps the comparison focused on the integration path rather than differences in the underlying algorithm.

  1. V8 + WebAssembly. The R package’s V8 engine loads the host-neutral WASM module. R communicates with JavaScript, which then crosses the WASM boundary.
  2. Wasmtime + WebAssembly. R calls Wasmtime through a small C shim. Wasmtime loads the same WASM module and executes the Rust core.
  3. Native Rust + extendr. R calls a platform-native Rust library through extendr, without a WebAssembly runtime.

A pure-R implementation is included as a reference baseline. It is not a fourth Rust integration strategy.

The important control is that the three Rust backends call the same core implementation and use the same JSON fixtures and contract. The experiment is about the integration path—not three independently written versions of matrix validation.

What exactly did we measure?

The CI benchmark uses six deterministic fixtures:

Each platform runs the same fixtures through V8 + WASM, Wasmtime + WASM, native Rust + extendr, and a pure-R reference implementation. The R runners warm up, calibrate the batch size, and collect ten timed samples. The reported value is the mean milliseconds per call, accompanied by a 95% confidence interval calculated from those ten within-run samples; lower is better.

The measurements include the complete R-to-backend path, including host/ABI and JSON conversion overhead. They are not measurements of the isolated Rust function. This distinction matters: a runtime can execute WASM quickly and still lose time moving values between R, JavaScript, C, and linear memory.

The current successful CI run covers three x86-64 architectures/platform combinations: Ubuntu Linux, Intel macOS, and Windows with the GNU/MSYS2 toolchain. It does not cover ARM64. The Linux and macOS runners report R 4.6.2; the Windows size-reporting script did not emit its R version.

The confidence intervals describe variation between timed samples on one CI runner. They do not represent variation between independent CI runs or different machines. Treat the numbers as directional measurements for this integration setup, not universal runtime rankings.

Results across three x86-64 platforms

Each cell shows the mean in milliseconds per call followed by its 95% within-run confidence interval. The four implementations share the same Rust validation contract and JSON fixtures; pure R is a reference baseline, not a Rust integration strategy.

Source run and downloadable data. All performance tables in this section use corrplot-ng workflow run #41404373498. All three platform jobs completed successfully. Each uploaded artifact contains a benchmark.csv with the six fixture results for all four backends, plus the corresponding fixture JSON outputs: Ubuntu Linux x86-64 artifact, Intel macOS x86-64 artifact, and Windows x86-64 artifact. That is 24 backend/fixture means per platform (72 across the three platforms), covering 18 fixture/platform combinations. The tables below match these uploaded CSVs. The checked-in report and CSV snapshot are older results and are not the source for the published numbers.

Ubuntu latest — Linux x86-64

Fixture V8 + WASM (ms) Wasmtime + WASM (ms) Native Rust (ms) Pure R (ms)
valid-small 0.0959 (0.0934–0.0984) 0.0037 (0.0037–0.0037) 0.0021 (0.0020–0.0021) 0.1637 (0.1615–0.1660)
valid-general-32 0.1523 (0.1495–0.1551) 0.0567 (0.0566–0.0569) 0.0306 (0.0305–0.0307) 1.9434 (1.9250–1.9618)
valid-general-128 1.0360 (1.0274–1.0446) 0.8265 (0.8234–0.8295) 0.4247 (0.4204–0.4289) 20.2128 (20.0231–20.4025)
valid-general-512 12.0627 (12.0171–12.1082) 12.5911 (12.5694–12.6129) 5.8183 (5.8014–5.8353) 257.5500 (253.5238–261.5762)
valid-correlation-64 0.3316 (0.3294–0.3338) 0.2415 (0.2407–0.2424) 0.1212 (0.1211–0.1214) 8.8739 (8.7627–8.9851)
invalid-asymmetric 0.1109 (0.1083–0.1136) 0.0042 (0.0042–0.0043) 0.0025 (0.0025–0.0025) 0.1350 (0.1326–0.1374)

macOS 15 Intel — Darwin x86-64

Fixture V8 + WASM (ms) Wasmtime + WASM (ms) Native Rust (ms) Pure R (ms)
valid-small 0.1114 (0.1041–0.1186) 0.0058 (0.0054–0.0062) 0.0036 (0.0034–0.0037) 0.3988 (0.3778–0.4198)
valid-general-32 0.2085 (0.2005–0.2165) 0.0743 (0.0668–0.0817) 0.0408 (0.0401–0.0415) 4.6140 (4.3984–4.8296)
valid-general-128 1.5987 (1.4916–1.7058) 1.0323 (0.9707–1.0939) 0.5334 (0.5120–0.5548) 36.7857 (34.3352–39.2362)
valid-general-512 21.2981 (20.1497–22.4466) 17.4465 (15.4723–19.4206) 10.8560 (10.1801–11.5319) 420.7000 (408.5988–432.8012)
valid-correlation-64 0.5313 (0.4898–0.5729) 0.2895 (0.2786–0.3003) 0.1705 (0.1558–0.1851) 15.3403 (14.7636–15.9169)
invalid-asymmetric 0.1408 (0.1289–0.1527) 0.0076 (0.0063–0.0088) 0.0046 (0.0043–0.0048) 0.2974 (0.2828–0.3119)

Windows latest — x86-64 / GNU toolchain

Fixture V8 + WASM (ms) Wasmtime + WASM (ms) Native Rust (ms) Pure R (ms)
valid-small 0.0803 (0.0788–0.0819) 0.0036 (0.0036–0.0036) 0.0022 (0.0021–0.0022) 0.4681 (0.4645–0.4717)
valid-general-32 0.1299 (0.1281–0.1317) 0.0589 (0.0586–0.0592) 0.0319 (0.0317–0.0322) 5.6228 (5.5544–5.6911)
valid-general-128 0.9410 (0.9290–0.9529) 0.8433 (0.8388–0.8479) 0.4287 (0.4261–0.4313) 40.8400 (40.4479–41.2321)
valid-general-512 10.0396 (9.9712–10.1080) 12.6125 (12.5342–12.6908) 5.8117 (5.7722–5.8512) 439.0000 (433.8427–444.1573)
valid-correlation-64 0.2770 (0.2738–0.2803) 0.2519 (0.2507–0.2531) 0.1229 (0.1223–0.1235) 16.9672 (16.8405–17.0939)
invalid-asymmetric 0.0983 (0.0964–0.1002) 0.0043 (0.0042–0.0043) 0.0027 (0.0027–0.0028) 0.3378 (0.3355–0.3401)

What stands out

The useful lesson is not simply “native is fastest”. It is that the cost of crossing boundaries can dominate a small operation. The more work each call performs, the easier it becomes to amortize that fixed cost.

The runtime can be much bigger than the module

The WASM module itself is small: about 144 KiB on all three platforms. The host runtime is the much larger part of the footprint.

Component Linux x86-64 macOS Intel x86-64 Windows x86-64
Installed V8 R package, including engine 47.5 MiB 39.1 MiB 34.5 MiB
Wasmtime shared library 93.9 MiB 65.5 MiB 102.7 MiB
WASM module 143.6 KiB 143.6 KiB 143.6 KiB
Current native extendr cdylib (standalone) 0.7509 MiB (787,392 B) 0.6336 MiB (664,392 B) 1.9173 MiB (2,010,425 B)

These are different artifact categories, not directly comparable end-user download sizes: the V8 figure is an installed R package directory, Wasmtime is a shared library, the Rust figure is a standalone native shared library, and the WASM figure is the module file alone. They do, however, illustrate an important practical point: a tiny portable module does not make its runtime free.

The native-library values in this table come from a separate controlled size workflow and are exact standalone cdylib measurements. That workflow also measured the legacy generated R package:

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, but larger on Windows in this run. The current native build uses matching linker debug-stripping settings on Linux and macOS; Windows remains unstripped. This is not a strict like-for-like package comparison: the legacy generated package uses extendr 0.9.0, while the current native crate declares extendr 0.8 as its Cargo semver requirement. The current adapter is not yet a separately installable R package, so there is no matching installed-package or source-tarball figure for it. The source archive in the table is for the legacy R package.

Native Rust has a small measured library artifact, but it comes with a different distribution obligation: build and ship the right native binary for each supported platform.

The controlled size workflow used Rust 1.99.1, Cargo 1.99.1, and R 4.6.2 on all three runners. Its target triples 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 Cargo.lock. For the current native cdylib, CI used -C link-arg=-Wl,--strip-debug on Linux and -C link-arg=-Wl,-S on macOS, and no strip flag on Windows. The legacy generated-package workflow separately records CORRPLOT_REXTENDR_STRIP_DEBUG=true; the JSON manifests include the R compiler and shared-library linker settings for each runner.

See the controlled binary-size experiment and the successful three-platform size workflow run #41428503394, with its Ubuntu artifact, Intel macOS artifact, and Windows artifact. These are the separate machine-readable sources for the installed-package and native-library footprint measurements; they should not be confused with the performance artifacts linked above.

What this experiment does not tell us

There are several gaps before these measurements could support a production decision:

The full benchmark report and raw CSV are available in the experiment results at the exact source commit, alongside the successful three-platform CI run and draft experiment PR #239.

So, WASM it worth it?

For this particular workload and integration setup, the current numbers favour native Rust + extendr when throughput and a small runtime footprint are the main goals. Wasmtime + WASM remains interesting when distributing a host-neutral module or sharing one compiled artifact across multiple runtimes is more valuable than the extra runtime dependency. V8 + WASM is functional, but its current integration path usually has the largest per-call overhead.

That is not a universal verdict against WebAssembly. WASM remains useful for browser execution and for environments where a portable sandboxed module is the right deployment unit. The best boundary depends on the host, the workload, and what needs to be shipped.

This experiment provides a useful baseline for choosing how Rust code should be integrated into R. A broader evaluation would still need cold-start and memory measurements, ARM64 coverage, independent repeated CI trials, and a more complete view of packaging and maintenance costs.

If there is one takeaway, it is this: the fastest way to call Rust may be not to put another runtime between R and Rust—but portability can be worth paying for.


This is an exploratory benchmark of a shared validation function, not a production performance guarantee or a final corrplot-ng backend decision.

← Back to all articles