Tools / Blog / How VaultTools Runs in Your Browser with Rust + WASM

How VaultTools Runs in Your Browser with Rust + WASM

· Antoine H.

Watch the video summary

45 tools. No backend. No Lambda. No S3. Everything runs in the browser tab where you dropped your file. This post is a technical walkthrough of how Vault-Tools is built, from Rust crate architecture to Wasm loading strategy to the hard problems we hit along the way.

Why Rust

We needed a language that compiles to WebAssembly with minimal overhead, runs at near-native speed, and has a mature ecosystem of libraries for file processing. Rust checks every box.

Memory safety without a garbage collector. Wasm modules don’t have access to the browser’s GC. Languages that rely on one (Go, Java, C#) have to ship their own runtime in the Wasm binary, inflating it significantly. Rust’s ownership model gives us memory safety at compile time with zero runtime overhead. No GC pauses, no runtime bloat.

Excellent Wasm target. The wasm32-unknown-unknown target is a first-class citizen in the Rust ecosystem. wasm-bindgen provides seamless interop between Rust and JavaScript. wasm-pack handles the build toolchain. The developer experience is genuinely good.

Rich crate ecosystem. This is what made the project viable. We needed libraries for image processing (image), PDF manipulation (lopdf), JSON/CSV/YAML parsing (serde_json, csv, serde_yaml), Markdown rendering (pulldown-cmark), QR code generation (qrcode), EXIF reading (kamadak-exif), and more. The Rust ecosystem had a compatible crate for almost everything.

Compact binaries. With wasm-opt and proper optimization flags, our Wasm modules range from roughly 200 KB to 2 MB depending on the crate. That’s the entire tool (logic, dependencies, everything) in a single file smaller than most JavaScript bundles.

The 5-Crate Architecture

Rather than compiling everything into a single monolithic Wasm module, we split the codebase into five crates by category:

  • dev-tools: hashing (SHA-1, SHA-256), color conversion, URL encoding, QR code generation and reading, UUID generation, JWT decoding, cron parsing, lorem ipsum, timestamp conversion
  • image-tools: format conversion, compression, resize, EXIF read/strip, image-to-base64, favicon generation, mockup rendering, color palette extraction, SVG-to-PNG, transforms (rotate/flip/crop), watermarking
  • text-tools: JSON format/validate, CSV-to-JSON, Base64 encode/decode, Markdown-to-HTML, YAML/JSON/TOML conversion, XML-to-JSON, HTML entity encoding, word count, text diff, regex testing
  • pdf-tools: merge, split, organize pages, images-to-PDF, metadata editing, compress, PDF-to-images, page numbers, watermark
  • file-tools: file checksum (MD5, SHA), TSV-to-CSV conversion, CSV viewer/editor, ZIP compress/extract

Each crate compiles to its own .wasm and .js glue file. When you visit the JSON formatter, your browser only downloads text_tools_bg.wasm, not the image processing code, not the PDF library, not any of it. Code splitting at the Wasm level.

Inside each crate, the architecture is one module per tool. The crate’s lib.rs only declares modules and re-exports the public API:

// image-tools/src/lib.rs
mod formats;
mod compress;
mod resize;
mod exif;
mod watermark;
// ...

pub use formats::*;
pub use compress::*;
// ...

Each module file (e.g., src/compress.rs) contains the #[wasm_bindgen] annotated functions that JavaScript can call.

Compilation Pipeline

A custom shell script (scripts/build-wasm.sh) iterates over all five crates:

for crate in dev-tools image-tools text-tools pdf-tools file-tools; do
  wasm-pack build "crates/$crate" --target web --release
  cp "crates/$crate/pkg/${crate}_bg.wasm" site/public/wasm/
  cp "crates/$crate/pkg/${crate}.js" site/public/wasm/
done

The --target web flag is important. It generates ES module glue code with an init() function that fetches and instantiates the Wasm binary, designed for direct use in browsers without a bundler.

The compiled outputs land in site/public/wasm/, which means they’re served as static assets. The Wasm binaries are committed to git because the Cloudflare build server doesn’t have a Rust toolchain, so there’s no compilation step in CI.

Loading Wasm in Astro

Vault-Tools uses Astro for the frontend. Astro’s component model posed an interesting challenge for Wasm loading.

Tool pages use <script is:inline> blocks instead of Astro’s default module scripts. Why? Module scripts are bundled and transformed by Vite at build time. But our Wasm initialization requires a dynamic import() at runtime: the browser needs to fetch the .js glue file, which in turn fetches the .wasm binary. Bundling breaks this chain.

The loading pattern looks like this:

// Inside <script is:inline>
async function initWasm() {
  const wasm = await import('/wasm/pdf_tools.js');
  await wasm.default();  // init() fetches and instantiates the .wasm binary
  return wasm;
}

The wasm.default() call is the init() function generated by wasm-bindgen. It handles fetching the .wasm file, compiling it via WebAssembly.instantiateStreaming, and wiring up the JavaScript-to-Wasm bindings.

The File Processing Pipeline

Every tool follows the same data flow:

User drops file
  → FileReader.readAsArrayBuffer()
    → new Uint8Array(buffer)
      → wasmModule.process_function(uint8array)
        → Uint8Array result
          → new Blob([result])
            → URL.createObjectURL(blob)
              → trigger download via <a> click

In code, the core of any file tool looks roughly like this:

const fileInput = document.getElementById('file-input');
const file = fileInput.files[0];
const buffer = await file.arrayBuffer();
const bytes = new Uint8Array(buffer);

const wasm = await initWasm();
const result = wasm.compress_pdf(bytes);  // Rust function

const blob = new Blob([result], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);

const a = document.createElement('a');
a.href = url;
a.download = 'compressed.pdf';
a.click();
URL.revokeObjectURL(url);

The entire operation happens in memory within the browser tab. No fetch(), no XMLHttpRequest, no data leaving the machine.

Hard Problems We Solved

Building 45 tools in Wasm wasn’t straightforward. Here are the trickiest issues we ran into.

Multi-file operations and the builder pattern

Wasm’s FFI boundary doesn’t support passing a Vec<Uint8Array> from JavaScript to Rust. This is a problem for tools like PDF merge or images-to-PDF that need multiple input files.

The solution is a builder pattern exposed via wasm_bindgen:

#[wasm_bindgen]
pub struct PdfMerger {
    documents: Vec<Vec<u8>>,
}

#[wasm_bindgen]
impl PdfMerger {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Self {
        Self { documents: Vec::new() }
    }

    pub fn add_document(&mut self, data: &[u8]) {
        self.documents.push(data.to_vec());
    }

    pub fn merge(&self) -> Vec<u8> {
        // merge logic
    }
}

JavaScript calls new PdfMerger(), then .add_document(bytes) for each file, then .merge() to get the result.

lopdf pinned to 0.35

The lopdf crate handles all our PDF operations. Versions after 0.35 introduced dependencies that don’t compile to wasm32-unknown-unknown. We had to pin the exact version (=0.35 in Cargo.toml) and work within its API constraints. Upgrading isn’t an option until upstream Wasm compatibility is restored.

The double requestAnimationFrame trick

When a user clicks a “Compress” button, we show a spinner, then call the Wasm function. The problem: Wasm processing is synchronous and blocks the main thread. If you set the spinner and immediately call Wasm, the browser hasn’t painted the spinner yet. The UI freezes with no feedback.

A single requestAnimationFrame isn’t enough because the browser may batch the callback before compositing. We use a double-rAF:

button.classList.add('btn-spinner');
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
// NOW the spinner is guaranteed to be visible
const result = wasm.compress_pdf(bytes);

This guarantees the browser has painted the loading state before the synchronous Wasm call blocks the thread.

Font subsetting for text watermarks

The page numbers and watermark tool renders text onto PDF pages using resvg with an embedded font. Embedding a full font (Inter Regular, ~400 KB) would bloat the Wasm binary. We subset the font to ASCII glyphs only using pyftsubset, bringing it down to 26 KB. The font is baked into the binary at compile time with include_bytes!("fonts/subset.ttf").

The hybrid JS+Wasm pattern for HEIC

HEIC (the default iPhone photo format) has no pure-Rust decoder that compiles to Wasm. We built a hybrid approach: an npm package (heic-convert or similar) handles the HEIC decoding in a bundled <script> tag and exposes the decoded pixel data on window. A separate <script is:inline> block picks up those pixels and passes them to the Wasm module for encoding to JPEG/PNG.

Two script blocks are required because is:inline scripts can’t import npm packages, and bundled scripts can’t do dynamic Wasm import(). It’s a pragmatic trade-off.

Performance Characteristics

Real-world numbers on a mid-range laptop (M1 MacBook Air):

  • PDF compression (10 MB file): ~2 seconds processing, zero upload time
  • Image format conversion (5 MB JPEG to PNG): ~1 second
  • JSON formatting (1 MB file): effectively instant
  • PDF merge (5 documents, 50 pages total): ~3 seconds

Compare this to server-based tools where the upload alone takes 10-30 seconds on a typical connection. The Wasm processing overhead is negligible compared to eliminated network latency.

Wasm binary sizes stay reasonable thanks to per-crate code splitting. If you’re formatting JSON, you download ~300 KB of text_tools_bg.wasm. If you’re compressing a PDF, you download ~1.5 MB of pdf_tools_bg.wasm. The browser caches these aggressively; second visit loads are instant.

The Stack

For the curious, the full stack:

  • Processing: Rust compiled to WebAssembly via wasm-pack and wasm-bindgen
  • Frontend: Astro with minimal JavaScript, Tailwind CSS v4
  • Hosting: Cloudflare Pages with Workers
  • Build: Custom shell script for Wasm, Astro for the site

No backend for processing. No serverless functions. No object storage. The “server” only serves static files and the occasional API endpoint for non-file-processing features.

View Source, Check the Network Tab

Everything described in this post is verifiable. Visit any tool on vault-tools.com. Open DevTools. Watch the Network tab. Process a file. The Wasm module loads once (that’s the tool itself), and after that, the network goes silent.

The code is the proof.