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.
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
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.
- V8 + WebAssembly. The R package’s V8 engine loads the host-neutral WASM module. R communicates with JavaScript, which then crosses the WASM boundary.
- Wasmtime + WebAssembly. R calls Wasmtime through a small C shim. Wasmtime loads the same WASM module and executes the Rust core.
- 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:
valid-smallvalid-general-32(32×32)valid-general-128(128×128)valid-general-512(512×512)valid-correlation-64(64×64)invalid-asymmetric
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
- Native Rust is the fastest implementation in every fixture on all three platforms. For the 512×512 general matrix, it takes about 5.8 ms on Linux and Windows, and 10.9 ms on Intel macOS. It is roughly 1.6–2.2× faster than the faster WASM route on that fixture.
- Wasmtime usually beats V8, particularly on small and medium inputs. The exception is the Windows 512×512 case, where V8 takes 10.04 ms and Wasmtime 12.61 ms.
- The pure-R reference is slower in this specific JSON validation benchmark, with the gap growing sharply for larger matrices. This is not a claim that R is generally slow: it compares this reference implementation and boundary against a shared Rust core.
- The confidence intervals are narrow in many Linux and Windows cases, but wider on macOS, especially for longer calls. They quantify within-run sample variation only; overlapping intervals should not be interpreted as a formal cross-machine significance test.
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:
- One CI run per platform. The 95% confidence intervals are based on ten timed samples within each run; there are no independent repeated runner trials or between-run confidence intervals.
- Warm calls only. Cold-start and first-call latency have not been measured.
- No memory profile. Peak resident memory and runtime initialization overhead still need measurement.
- x86-64 only. The current run covers Linux x86-64, Intel macOS x86-64, and Windows x86-64 with the GNU/MSYS2 toolchain. ARM64 is not covered.
- The JSON boundary is deliberate. The native adapter uses JSON too, so the comparison preserves a common contract. A typed R/native API might be faster, but would no longer be the same boundary comparison.
- The package footprint is incomplete. Full dependency closure, source package size, user download size, and installation experience need separate measurements.
- This is one operation, not a full application benchmark. It measures the shared matrix-validation boundary and synthetic fixtures, not corrplot rendering or end-to-end package workflows.
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.