← Research

// article

WebAssembly visualization with Rust: when JavaScript runs out of room

September 10, 2024 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.

When to reach for it

The signals that JavaScript is the wrong tool:

  • More than ~100K points need to update every frame
  • The per-frame math is non-trivial (physics, spatial indexing, large matrix ops)
  • Memory pressure causes garbage-collection pauses you can see
  • A 60fps target is non-negotiable

If none of those apply, stay in JavaScript. The WASM round-trip is real, and the tooling is heavier.

Setup

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",
]

The particle system

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()
            };
        }
    }
}

Driving it from JavaScript

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.

Pushing further

Memory management

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();
    }
}

Large datasets

Past about a million points, the rendering cost dominates and you’ll want to be selective about what you draw:

  • Spatial indexing — quadtrees or R-trees, depending on the access pattern
  • Level-of-detail — fewer points at lower zoom levels
  • Chunked updates — process and render in batches across frames
  • WebGL via web-sys — for any case where Canvas2D is the bottleneck
use web_sys::{WebGlRenderingContext, WebGlBuffer};

#[wasm_bindgen]
pub struct WebGLParticleSystem {
    gl: WebGlRenderingContext,
    vertex_buffer: WebGlBuffer,
    position_data: Vec<f32>,
}

Profiling

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);
}

What I’ve used this for

  • Trading dashboards with 100K+ live candlesticks
  • Particle-physics demos
  • Geospatial overlays with millions of points
  • Neural-network topology rendering for explainability work

Measured against pure JS

On the same workloads (50K particles, 60fps target, comparable rendering paths):

  • Initialization: 3–5× faster
  • Per-frame update: 2–8× faster
  • Resident memory: 40–60% lower
  • 60fps sustained at 50K+ particles where the JS version drops to 20–30fps

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.