// article
JavaScript is fast enough for most data viz on the web. It stops being fast enough somewhere around 100K live points, especially with per-frame computation. WebAssembly closes that gap. Compiling Rust to WASM gives you near-native compute inside the browser, with the JS interop layer thin enough that you don’t lose the wins.
This piece walks through building a particle system in Rust, compiling it to WASM, and driving it from a canvas in a regular HTML page. The code is the smallest thing that demonstrates the workflow — extend it later for whatever the real visualization is.
The signals that JavaScript is the wrong tool:
If none of those apply, stay in JavaScript. The WASM round-trip is real, and the tooling is heavier.
cargo install wasm-pack
cargo new --lib wasm-visualization
cd wasm-visualization
Cargo.toml:
[package]
name = "wasm-visualization"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.6"
[dependencies.web-sys]
version = "0.3"
features = [
"CanvasRenderingContext2d",
"Document",
"Element",
"HtmlCanvasElement",
"Window",
"console",
]
A minimal ParticleSystem with update and render methods. The Rust side owns the data; JavaScript only sees the handle.
use wasm_bindgen::prelude::*;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
use js_sys::Math;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
macro_rules! console_log {
($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}
#[wasm_bindgen]
pub struct ParticleSystem {
particles: Vec<Particle>,
width: f64,
height: f64,
}
struct Particle {
x: f64,
y: f64,
vx: f64,
vy: f64,
color: String,
size: f64,
data_value: f64,
}
#[wasm_bindgen]
impl ParticleSystem {
#[wasm_bindgen(constructor)]
pub fn new(width: f64, height: f64, count: usize) -> ParticleSystem {
let mut particles = Vec::with_capacity(count);
for _ in 0..count {
particles.push(Particle {
x: Math::random() * width,
y: Math::random() * height,
vx: (Math::random() - 0.5) * 2.0,
vy: (Math::random() - 0.5) * 2.0,
color: format!("hsl({}, 70%, 50%)", Math::random() * 360.0),
size: Math::random() * 5.0 + 2.0,
data_value: Math::random(),
});
}
ParticleSystem {
particles,
width,
height,
}
}
pub fn update(&mut self, delta_time: f64) {
for particle in &mut self.particles {
particle.x += particle.vx * delta_time;
particle.y += particle.vy * delta_time;
if particle.x <= 0.0 || particle.x >= self.width {
particle.vx *= -1.0;
}
if particle.y <= 0.0 || particle.y >= self.height {
particle.vy *= -1.0;
}
particle.x = particle.x.clamp(0.0, self.width);
particle.y = particle.y.clamp(0.0, self.height);
}
}
pub fn render(&self, context: &CanvasRenderingContext2d) {
context.clear_rect(0.0, 0.0, self.width, self.height);
for particle in &self.particles {
context.begin_path();
context.set_fill_style(&JsValue::from_str(&particle.color));
context
.arc(
particle.x,
particle.y,
particle.size,
0.0,
2.0 * std::f64::consts::PI,
)
.unwrap();
context.fill();
}
}
pub fn add_data_point(&mut self, x: f64, y: f64, value: f64) {
if let Some(particle) = self.particles.iter_mut().find(|p| p.data_value == 0.0) {
particle.x = x;
particle.y = y;
particle.data_value = value;
particle.size = value * 10.0;
particle.color = if value > 0.5 {
"rgb(255, 100, 100)".to_string()
} else {
"rgb(100, 100, 255)".to_string()
};
}
}
}
wasm-pack build --target web --out-dir pkg
<!DOCTYPE html>
<html>
<head>
<title>WASM Data Visualization</title>
<style>
canvas {
border: 1px solid #ccc;
display: block;
margin: 20px auto;
}
.controls {
text-align: center;
margin: 20px;
}
</style>
</head>
<body>
<div class="controls">
<button id="addData">Add Random Data</button>
<button id="reset">Reset</button>
<span id="fps">FPS: 0</span>
</div>
<canvas id="canvas" width="800" height="600"></canvas>
<script type="module">
import init, { ParticleSystem } from './pkg/wasm_visualization.js';
async function run() {
await init();
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const system = new ParticleSystem(800, 600, 5000);
let lastTime = 0;
let frameCount = 0;
let lastFpsUpdate = 0;
function animate(currentTime) {
const deltaTime = (currentTime - lastTime) / 1000.0;
lastTime = currentTime;
system.update(deltaTime);
system.render(ctx);
frameCount++;
if (currentTime - lastFpsUpdate > 1000) {
document.getElementById('fps').textContent =
`FPS: ${frameCount}`;
frameCount = 0;
lastFpsUpdate = currentTime;
}
requestAnimationFrame(animate);
}
document.getElementById('addData').addEventListener('click', () => {
for (let i = 0; i < 10; i++) {
system.add_data_point(
Math.random() * 800,
Math.random() * 600,
Math.random()
);
}
});
document.getElementById('reset').addEventListener('click', () => {
system.free();
system = new ParticleSystem(800, 600, 5000);
});
requestAnimationFrame(animate);
}
run();
</script>
</body>
</html>
The JS side is a thin shell: load WASM, instantiate the system, drive requestAnimationFrame. All the work happens inside update and render, which are Rust.
Rust’s ownership rules carry over, but WASM doesn’t garbage-collect the structs that JavaScript holds handles to. Call free() on the JS side when you’re done, or expose a destroy method from Rust.
#[wasm_bindgen]
impl ParticleSystem {
pub fn destroy(&mut self) {
self.particles.clear();
self.particles.shrink_to_fit();
}
}
Past about a million points, the rendering cost dominates and you’ll want to be selective about what you draw:
web-sys — for any case where Canvas2D is the bottleneckuse web_sys::{WebGlRenderingContext, WebGlBuffer};
#[wasm_bindgen]
pub struct WebGLParticleSystem {
gl: WebGlRenderingContext,
vertex_buffer: WebGlBuffer,
position_data: Vec<f32>,
}
Use the browser’s profiler for end-to-end measurement and performance.now() from inside Rust for per-function timing.
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = performance)]
fn now() -> f64;
}
pub fn benchmark_update(&mut self) {
let start = now();
self.update(0.016); // 60 FPS
let end = now();
console_log!("Update took: {}ms", end - start);
}
On the same workloads (50K particles, 60fps target, comparable rendering paths):
The setup cost is real — a Rust toolchain, wasm-pack, the JS glue. Don’t pay it unless the dataset size or per-frame math actually demands it. When it does, the wins are concrete and reproducible.