Skip to content
HexaTransfer
Back to blog
Encryption & Security

WebAssembly Encryption: Near-Native Crypto Speed in Browser

Use WebAssembly to achieve near-native encryption speeds in the browser. Compare WASM crypto implementations and learn when to use WASM over pure JavaScript.

WASM-compiled crypto libraries run at roughly 60-80% of native C speed in the browser, which is 3-10x faster than pure JavaScript implementations. For encryption primitives missing from the Web Crypto API (ChaCha20-Poly1305, Argon2id, XChaCha20, post-quantum KEMs like Kyber), WASM is the practical path to usable performance. libsodium.js is the dominant option, providing the full libsodium API with a 200 KB WASM binary. For Web Crypto-supported primitives like AES-256-GCM, the browser's native hardware-accelerated implementation beats WASM handily, so WASM isn't always the right tool. This guide covers when to reach for it and how to benchmark the trade-offs.

When Native Web Crypto Wins

For algorithms Web Crypto already exposes — AES-GCM, AES-CBC, AES-CTR, PBKDF2, HMAC, RSA-OAEP, ECDH, ECDSA, SHA-256/384/512 — the browser's native implementation uses hardware acceleration via AES-NI on x86 and ARMv8 crypto extensions on mobile. Typical AES-256-GCM throughput:

  • Web Crypto (Chrome on Apple Silicon): 1.7 GB/s
  • Web Crypto (Chrome on Intel x86 with AES-NI): 1.2 GB/s
  • libsodium WASM AES-GCM: 400-800 MB/s
  • Native OpenSSL reference: 3-5 GB/s

WASM doesn't have access to AES-NI from inside the sandbox; it falls back to bitsliced AES implementations which are slower but constant-time. For anything Web Crypto supports, use it first.

Where WASM Wins

For algorithms missing from Web Crypto:

  • ChaCha20-Poly1305: faster than AES on devices without AES-NI. No native browser support in 2026.
  • XChaCha20-Poly1305: 192-bit nonces make random-nonce use safe at any scale.
  • Argon2id: the PHC-winning password-hashing function. No browser native support.
  • X25519 / Ed25519: Safari 17 and Firefox 129 added native support, but WASM is still the portable path.
  • BLAKE2b / BLAKE3: fast hash functions not in Web Crypto.
  • Kyber, Dilithium, SPHINCS+: post-quantum primitives; library territory only.
  • Streaming AEAD: libsodium's crypto_secretstream handles large-file streaming encryption cleanly; Web Crypto has no equivalent.

For these, JavaScript implementations exist but are 5-20x slower than WASM. On a 1 GB file with ChaCha20-Poly1305, WASM runs in ~2 seconds while pure JS takes 15-40 seconds.

libsodium.js: The Workhorse

libsodium-wrappers (the Emscripten-built libsodium with JS wrappers) is the go-to library. 200 KB WASM + ~50 KB JS wrapper, gzipped.

import _sodium from 'libsodium-wrappers';
await _sodium.ready;
const sodium = _sodium;

// ChaCha20-Poly1305 authenticated encryption
const key = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
  plaintext, null, null, nonce, key
);

The "sumo" build (includes more algorithms) is ~600 KB; the default build covers 90% of use cases including AEAD, password hashing (Argon2id), X25519, and Ed25519.

Load dynamically so the WASM binary doesn't block initial page render:

async function getSodium() {
  if (!window._sodium) {
    const mod = await import('libsodium-wrappers');
    await mod.ready;
    window._sodium = mod;
  }
  return window._sodium;
}

Streaming AEAD via crypto_secretstream

For large file transfer, libsodium's crypto_secretstream_xchacha20poly1305 is the cleanest streaming AEAD in the browser:

const { state, header } = sodium.crypto_secretstream_xchacha20poly1305_init_push(key);
const chunk1 = sodium.crypto_secretstream_xchacha20poly1305_push(
  state, plaintext1, null,
  sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE
);
// Last chunk uses TAG_FINAL so recipient detects truncation
const chunkLast = sodium.crypto_secretstream_xchacha20poly1305_push(
  state, plaintextLast, null,
  sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
);

The TAG_FINAL marker lets the recipient detect truncation attacks — if an attacker drops trailing chunks, decryption on the client fails. Web Crypto's AES-GCM doesn't have this property; you'd need to build truncation detection yourself (chunk count in AAD, or an overall file hash verified post-decrypt).

Argon2 in the Browser

For password-based key derivation in the browser, Argon2id via WASM is the 2026 best practice. Options:

  • argon2-browser: dedicated Argon2 WASM library, ~200 KB
  • libsodium.js: Argon2id via crypto_pwhash, if you already use libsodium
  • @noble/hashes: pure-JS Argon2id, ~15 KB bundle, 3-5x slower than WASM

Example with argon2-browser:

import argon2 from 'argon2-browser';
const result = await argon2.hash({
  pass: password,
  salt: salt, // Uint8Array, 16+ bytes
  type: argon2.ArgonType.Argon2id,
  time: 3,
  mem: 65536, // KiB, so 64 MiB
  parallelism: 4,
  hashLen: 32,
});
// result.hash is a Uint8Array you can use as an AES-256 key

Calibrate parameters to your target user wait. OWASP 2024 baseline (m=19 MiB, t=2, p=1) runs in ~300-500 ms. Stronger (m=64 MiB, t=3, p=4) runs in ~1-2 seconds on modern hardware.

Post-Quantum Crypto via WASM

Kyber (key encapsulation) and Dilithium (signatures) were standardized by NIST in 2024 as ML-KEM and ML-DSA. JavaScript implementations exist (pq-crystals has a reference) but WASM versions like liboqs-js are faster and more auditable.

For file transfer, post-quantum KEMs let you wrap a symmetric file key with a quantum-safe public-key primitive. Hybrid schemes (ML-KEM + X25519) protect against both classical and future quantum attackers. Chrome shipped ML-KEM in TLS handshakes in version 116 (2023), but application-level WASM remains the path for file content keys.

Bundle Size Considerations

WASM binaries ship as part of your JS bundle or lazily fetched. Rough sizes (gzipped):

  • libsodium.js default: 200 KB
  • libsodium.js sumo: 600 KB
  • argon2-browser: 200 KB
  • liboqs-js (post-quantum): 1 MB+

For a landing page that encrypts in-browser, 200-300 KB of WASM is tolerable if lazy-loaded after user interaction. For an app that does encryption as the primary feature, shipping WASM on initial load is reasonable. Use rel="modulepreload" hints or service worker caching to keep subsequent loads instant.

Check network panel in browser dev tools to confirm the WASM binary is cached after first load. Misconfigured cache headers can cause re-downloads on every visit.

Compilation and Instantiation Overhead

WebAssembly.instantiate() parses and compiles the binary, which takes 20-100 ms for a 200 KB module on desktop, 100-500 ms on mobile. This happens once per session. Cache the compiled module in IndexedDB via WebAssembly.Module serialization for faster subsequent loads.

WebAssembly.instantiateStreaming() pipelines download and compilation, saving 30-50% of startup time compared to instantiate() on a fetched response:

const response = fetch('/sodium.wasm');
const { instance } = await WebAssembly.instantiateStreaming(response, importObject);

Memory Management Gotchas

WASM modules allocate memory in linear pages (64 KiB each). libsodium defaults to a small initial memory and grows as needed, but unbounded growth can hit browser limits (typically 4 GB linear memory cap in 32-bit WASM). For multi-GB file encryption:

  • Stream chunks through the WASM module, don't load full file into WASM memory
  • Free buffers after each chunk via sodium.memzero or by nulling references
  • Monitor WebAssembly.Memory.buffer.byteLength in long-running sessions

Memory64 (proposed, partially implemented in Chrome) removes the 4 GB cap. For now, chunked processing is the reliable path.

Security Considerations Specific to WASM

WASM runs in the same origin as the page that loaded it, inheriting the same CSP and CORS rules. If an attacker injects code into your origin (XSS), they can call into your WASM module's exports. WASM doesn't provide an extra security boundary.

Constant-time guarantees: most crypto libraries compiled to WASM preserve constant-time properties from the C source, but the WASM-to-JIT translation can introduce variable-time code on some platforms. Audit logs from libsodium note a few WASM-specific constant-time concerns. For most threat models this is negligible; for defense against local attackers with precise timing measurements, consider the gap.

The Practical Recipe

For a file transfer app in 2026:

  • Use Web Crypto for AES-256-GCM, PBKDF2, HMAC, SHA-256, RSA-OAEP if you need RSA
  • Use libsodium.js via WASM for Argon2id password hashing
  • Use libsodium.js for streaming encryption of large files (crypto_secretstream)
  • Use native Ed25519/X25519 where supported, fall back to libsodium otherwise
  • Lazy-load WASM after user interaction to keep initial bundle light
  • Benchmark on target devices; don't assume desktop numbers match mobile

HexaTransfer stays on Web Crypto AES-GCM for the bulk file encryption because the hardware-accelerated path is fastest. WASM is reserved for algorithms native APIs can't deliver.

Try it at hexatransfer.com — free, no account, 10 GB max.

Send large files securely with end-to-end encryption

Transfer files up to 10 GB for free with end-to-end encryption. No account required. Your files are encrypted in your browser before upload — no one else can read them.

Send a file