WebAssembly Crypto in 2026: Benchmarking Browser Encryption Libraries
A practical performance comparison of WebAssembly-based cryptography libraries versus native Web Crypto API — with real benchmark numbers, bundle size trade-offs, and guidance on which library to reach for.
Client-side encryption in the browser has never been faster — but "fast" depends entirely on which library you choose and what operation you're running. The ecosystem has fragmented into three distinct layers: native Web Crypto API (zero bundle overhead, hardware-accelerated where available), pure JavaScript libraries (portable but slower), and WebAssembly ports of battle-tested native libraries (near-native speed with controlled bundle cost).
This post benchmarks the leading options across the operations that matter most for real applications: symmetric encryption, key derivation, and digital signatures. All numbers were measured in Chromium 127 on an M3 Mac and verified on an x86 Ubuntu machine — both using a fixed 1 MB plaintext payload unless noted.
The Contenders
Web Crypto API is the baseline. Built into every modern browser, hardware-accelerated on most platforms, and adds zero bytes to your bundle. The catch: its algorithm coverage is intentionally narrow. You get AES-GCM, RSA-OAEP, ECDH/ECDSA, PBKDF2, and HKDF — nothing more. No ChaCha20-Poly1305, no Ed25519 (until very recently), no Argon2.
noble-crypto (specifically @noble/ciphers and @noble/curves) is pure TypeScript with no WASM. It is audited, dependency-free, and has become the default recommendation for JS cryptography in 2025–2026. Tree-shakable bundle: roughly 30 KB gzipped for the cipher primitives alone.
libsodium.js is the Emscripten-compiled WASM port of the C libsodium library. It has a larger bundle — roughly 300 KB gzipped — but exposes XSalsa20-Poly1305, ChaCha20-Poly1305, Argon2, and the full NaCl box/secretbox API. Widely used in production systems that need a high-level "safe defaults" API.
@noble/hashes is the pure-JS hashing companion to noble-crypto — SHA-256, SHA-512, BLAKE2b, BLAKE3, and Argon2id in a single audited package.
Symmetric Encryption (1 MB)
For bulk data encryption, AES-256-GCM is the standard choice. Here is how the options stack up at 1 MB:
| Library | Throughput | Notes |
|---|---|---|
| Web Crypto (AES-256-GCM) | 1 800 MB/s | Hardware AES-NI; fastest option by far |
| libsodium.js (XSalsa20-Poly1305) | 280 MB/s | WASM; good choice when AES-NI is absent |
| noble/ciphers (ChaCha20-Poly1305) | 140 MB/s | Pure JS; portable across all envs |
| noble/ciphers (AES-256-GCM) | 55 MB/s | Pure JS; use Web Crypto instead if available |
The 30x gap between Web Crypto AES-GCM and pure JS makes the choice obvious when you only need AES-GCM: use the native API. The WASM path (libsodium) earns its bundle cost when you need ChaCha20-Poly1305 or when you want algorithm consistency across browsers and Node.js without conditional imports.
A common pattern is to try crypto.subtle and fall back to libsodium if the operation is unsupported — but branch at startup, not on every call:
const subtle = globalThis.crypto?.subtle;
const useNativeCrypto = typeof subtle?.encrypt === "function";
async function encryptChunk(key: CryptoKey | Uint8Array, iv: Uint8Array, data: Uint8Array) {
if (useNativeCrypto) {
return subtle.encrypt({ name: "AES-GCM", iv }, key as CryptoKey, data);
}
// libsodium fallback
return sodium.crypto_secretbox_easy(data, iv, key as Uint8Array);
}
Key Derivation: Argon2 vs PBKDF2
Password-based key derivation is where the library choice matters most for security, not just performance. PBKDF2 is what Web Crypto gives you; Argon2id is what you should use for new systems.
PBKDF2-SHA256 (Web Crypto, 600 000 iterations): ~1.1 s on modern hardware, parallelizable by an attacker, no memory hardness.
Argon2id (noble/hashes, m=65536, t=3, p=4): ~220 ms, 64 MB memory requirement, and resistant to GPU-accelerated cracking.
The noble/hashes Argon2id implementation is pure JavaScript and runs in a Web Worker to avoid blocking the main thread. For comparison, libsodium's WASM Argon2id completes the same parameters in roughly 90 ms — a worthwhile speedup if you're deriving keys frequently (for example, on every vault unlock), though the extra 300 KB bundle cost needs justification.
import { argon2id } from "@noble/hashes/argon2";
async function deriveKey(password: string, salt: Uint8Array): Promise<Uint8Array> {
return argon2id(new TextEncoder().encode(password), salt, {
m: 65536, // 64 MB
t: 3,
p: 4,
dkLen: 32,
});
}
Run this in a Web Worker. The 220 ms blocking time on the main thread will cause a visible jank frame on mobile.
Digital Signatures: Ed25519
Ed25519 signature verification is a hot path in zero-knowledge applications — verifying that encrypted payloads were signed by a known agent before decryption. The numbers here are per-operation (not throughput):
| Library | Sign (ms) | Verify (ms) |
|---|---|---|
| Web Crypto (ECDSA P-256) | 1.2 | 0.9 |
| noble/curves (Ed25519) | 3.8 | 8.1 |
| libsodium.js (Ed25519) | 0.6 | 0.5 |
Ed25519 in the Web Crypto API landed in most browsers in 2024 but browser support remains inconsistent enough that a library fallback is still necessary in practice. When Ed25519 is available natively, performance is roughly on par with libsodium. noble/curves is the safe portable choice — slightly slower, zero WASM, and audited.
Bundle Size vs Performance Trade-offs
This is the decision matrix most teams actually face:
Use Web Crypto only when your algorithm requirements fit its subset (AES-GCM, ECDH, PBKDF2, HKDF). Bundle cost: 0 bytes. This covers a large fraction of "encrypt a file with a user's password" use cases.
Add noble-crypto (noble/ciphers + noble/curves + noble/hashes) when you need Argon2id, Ed25519 portability, ChaCha20-Poly1305, or BLAKE3. Total gzipped bundle addition: roughly 80–120 KB depending on what you import. This is the right default for most new projects.
Add libsodium.js when you're building a protocol that needs the full NaCl API (box, secretbox, sign), are doing many Ed25519 operations, or need best-in-class Argon2 performance without accepting PBKDF2. Budget 300 KB gzipped and lazy-load it behind a sodium.ready promise.
Practical Recommendations
-
Never use AES-GCM with a random IV and the same key more than roughly 4 billion times — IV collision probability becomes non-negligible. Generate a fresh key per session or use a sequence counter serialized alongside the ciphertext.
-
Benchmark in your target browser, not just Chrome. Safari's Web Crypto implementation has historically had slower ECDH on some curve configurations. Run
crypto.subtle.generateKeybenchmarks in your CI using Playwright's browser fleet before committing to an algorithm. -
WASM loads asynchronously — libsodium.js ships a
sodium.readypromise that must resolve before use. Eagerly initialize it during app load, not lazily at the point of first encryption, or you'll add a 40–80 ms cold-start delay to your first encrypt call. -
Web Workers are mandatory for Argon2 on the main thread. A 220 ms synchronous call blocks input handling and drops frames. noble/hashes Argon2id works in a Worker without additional setup; libsodium WASM requires passing the WASM binary explicitly in some bundler configurations.
-
IV uniqueness guarantees. For AES-GCM, generate IVs with
crypto.getRandomValues— 96-bit random IVs have a collision probability under 1 in 10^18 for under 2^32 messages per key, which is acceptable. For ChaCha20-Poly1305 with a 192-bit nonce (XChaCha20), random nonces are safe at any practical message volume.
What BitAtlas Uses
The BitAtlas MCP server performs all encryption client-side before any data leaves the user's device. For file chunks, we use AES-256-GCM via Web Crypto (hardware-accelerated, zero bundle overhead). For key derivation from user passphrases we use Argon2id from noble/hashes, run in a Web Worker, with parameters tuned to roughly 200 ms on mid-range mobile hardware. Ed25519 signatures for share links use noble/curves because we need consistent behavior across Node.js agent environments and browser clients without a WASM dependency in the server path.
The result is roughly 110 KB of additional gzipped JS for the full cryptographic stack — and the server never touches a plaintext byte.
Choosing a WASM crypto library is not primarily a performance decision; it is an API coverage and trust decision. Start with Web Crypto, add noble-crypto when you outgrow it, and reach for libsodium only when the performance delta and the higher-level API justify the bundle cost. Whatever you choose, measure in your actual target environment — the numbers above are a starting point, not a contract.