Skip to content
HexaTransfer
Back to blog
Encryption & Security

Encryption Performance Optimization: Fast Crypto in the Browser

Optimize encryption performance for large file transfers in the browser. Streaming encryption, Web Workers, and chunk processing techniques for fast crypto.

Encrypting a 5 GB file in a browser without killing the UI requires a specific set of techniques: Web Crypto API for hardware-accelerated AES-256-GCM (1-2 GB/s with AES-NI), chunked processing in 1-4 MB blocks to bound memory, Web Workers to keep the main thread responsive, streaming reads via File.stream() instead of FileReader.readAsArrayBuffer(), and careful nonce management so chunks can be encrypted in parallel. Pure-JavaScript crypto libraries run 10-20x slower than Web Crypto and should be reserved for primitives the browser doesn't expose natively. Here's how to hit multi-hundred megabyte-per-second throughput on real user hardware.

Baseline Measurements First

Before optimizing, measure. On a 2024 MacBook Air M2 in Chrome 120, encrypting a 1 GB buffer with AES-256-GCM via Web Crypto in a tight loop runs at ~1.7 GB/s. The same operation on a mid-range Android phone (Pixel 7) runs at ~600 MB/s. A 2015 Intel laptop with software-only AES hits ~250 MB/s.

What this tells you: Web Crypto AES-GCM is not the bottleneck for most encrypted file transfer flows. The bottleneck is usually file reading, JavaScript marshaling between ArrayBuffers, or network upload. Optimize those first.

Benchmarks to run in-app:

const blob = new Uint8Array(1024 * 1024 * 100); // 100 MB
const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 }, true, ["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const start = performance.now();
await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, blob);
console.log(`${(100 / (performance.now() - start) * 1000).toFixed(0)} MB/s`);

Run on Chrome, Firefox, Safari. Chrome on Apple Silicon will be fastest; Firefox a bit slower; Safari on Intel slightly behind. Mobile runs at roughly 30-50% of desktop speed.

Use Web Crypto, Not JavaScript Libraries

For AES-GCM, AES-CBC, PBKDF2, HMAC, RSA, ECDH, ECDSA, and SHA-256/384/512, the browser's native Web Crypto API uses hardware acceleration where available (AES-NI on x86, ARMv8 crypto extensions on mobile). JavaScript libraries like @noble/ciphers or pure-JS crypto-js cannot access these instructions and run purely in the interpreter.

Typical speed ratio for AES-256-GCM on 1 MB inputs:

  • Web Crypto (hardware accel): 1-2 GB/s
  • libsodium.js WASM: 400-800 MB/s
  • @noble/ciphers pure JS: 100-200 MB/s
  • crypto-js pure JS: 30-80 MB/s

For a 5 GB file, the difference between Web Crypto and pure JS is ~3 seconds versus ~50 seconds. User-visible. Always prefer Web Crypto for what it supports. Use WASM libraries (libsodium.js, argon2-browser) only for algorithms Web Crypto doesn't have (ChaCha20-Poly1305, Argon2id, X25519 on older browsers).

Chunk Processing for Large Files

Files over ~500 MB don't fit comfortably in a single ArrayBuffer on most devices. Chunk them into 1-4 MB pieces and encrypt each.

const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB

async function encryptLargeFile(file, key) {
  const chunks = [];
  let chunkIndex = 0;
  for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
    const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
    const iv = new Uint8Array(12);
    new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
    const ct = await crypto.subtle.encrypt(
      { name: "AES-GCM", iv }, key, chunk
    );
    chunks.push({ iv, ct: new Uint8Array(ct) });
  }
  return chunks;
}

Why 4 MB chunks? Smaller chunks (e.g., 64 KB) incur per-call overhead from the Web Crypto API that dominates for small inputs. Larger chunks (e.g., 64 MB) don't fit well in L2/L3 cache and have worse memory pressure. 1-4 MB tends to be the sweet spot on desktop and mobile alike.

Web Workers to Keep UI Responsive

Encryption on the main thread blocks rendering and input. For anything over ~500 ms of work, offload to a Web Worker.

// worker.js
self.onmessage = async (e) => {
  const { chunk, key, iv } = e.data;
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, chunk
  );
  self.postMessage(ciphertext, [ciphertext]);
};

// main thread
const worker = new Worker('/worker.js');
worker.postMessage({ chunk, key, iv }, [chunk]);
worker.onmessage = (e) => { /* handle ct */ };

Transfer ArrayBuffers with the second argument to postMessage — this moves ownership (zero-copy) rather than cloning. Without transfer, cloning a 4 MB chunk adds ~20 ms of overhead per chunk.

Web Crypto API is available in Workers, so the actual encryption runs there with identical performance to the main thread.

Parallel Chunk Encryption

Per-chunk AES-GCM encryption is independent as long as nonces don't collide. Derive nonces deterministically from chunk index and you can encrypt chunks in parallel across multiple Workers. Diminishing returns set in after ~4 workers on typical hardware because Web Crypto is so fast that the bottleneck shifts to file reading and inter-thread message passing. Benchmark before committing to complexity; sometimes single-worker sequential encryption is as fast as multi-worker parallel due to overhead.

Streaming with Readable Streams

For truly huge files (20+ GB), avoid loading even chunks into memory all at once. Use File.stream():

const reader = file.stream().getReader();
const writer = uploadStream.getWriter();
let chunkIndex = 0;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setBigUint64(4, BigInt(chunkIndex++));
  const ct = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, value
  );
  await writer.write(new Uint8Array(ct));
}
await writer.close();

This keeps memory use bounded to one chunk at a time. The browser reads from disk, encrypts, writes to the upload stream, reads the next chunk. Memory usage stays under 10 MB regardless of file size.

Upload Concurrency

Encryption runs in parallel with upload, not serially. While chunk N uploads, chunk N+1 is being encrypted. Use a pipeline:

const encryptQueue = [];
const uploadQueue = [];
const MAX_IN_FLIGHT = 4;

// Encryptor produces, uploader consumes
async function pipeline() {
  // Start encryption of first chunks
  for (let i = 0; i < MAX_IN_FLIGHT; i++) startEncrypt(i);
  // As each completes, start upload and enqueue next encryption
  // (bounded queue keeps memory use predictable)
}

TCP slow-start and TLS handshake overhead mean initial uploads are slow. Maintaining 4-8 concurrent uploads to a single origin keeps the pipe full without tripping browser limits (6 connections per origin in Chrome/Firefox).

WebAssembly for Missing Primitives

For Argon2id key derivation or ChaCha20-Poly1305, Web Crypto has no native support. WASM libraries fill the gap:

  • libsodium.js provides Argon2id, XChaCha20-Poly1305, crypto_secretstream
  • argon2-browser provides only Argon2 but smaller bundle
  • @noble/hashes provides pure-JS Argon2 (slower) with tiny bundle

WASM versions achieve 60-80% of native speed for most crypto workloads. For file transfer, a 1-2 second Argon2id derivation on upload and download is acceptable; a 5 second pure-JS derivation is not.

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

const sodium = await import('libsodium-wrappers');
await sodium.ready;

Progress Reporting

Large encryptions need progress feedback or users assume the app froze. Count encrypted bytes and post progress events:

let processed = 0;
for await (const chunk of chunks) {
  await encryptChunk(chunk);
  processed += chunk.size;
  onProgress({ done: processed, total: file.size, pct: processed / file.size });
}

Throttle UI updates to ~10 Hz via requestAnimationFrame or a simple timestamp check; more frequent updates waste cycles on repaints that humans can't see.

Memory Ceiling and GC Pressure

Each ArrayBuffer lives until it's no longer referenced. Holding 20 encrypted chunks of 4 MB each means ~80 MB of memory pinned. On mobile browsers with tight memory limits (iOS Safari caps at ~200-400 MB per tab), this matters.

Release references promptly:

for (let i = 0; i < chunks.length; i++) {
  const chunk = chunks[i];
  chunks[i] = null; // let GC reclaim
  const ct = await encrypt(chunk);
  await upload(ct);
}

Avoid keeping a full array of encrypted chunks in memory; stream them to the uploader and drop references as you go.

Real-World Targets

For a 1 GB encrypted file transfer in a browser tab: encryption time 1-3 seconds (hardware accelerated), upload time 30 seconds on a 300 Mbps connection, total around 35 seconds wall clock (mostly network-bound), memory under 50 MB peak with proper streaming, UI responsive throughout (main thread never blocked >50 ms). Miss those targets and users notice. HexaTransfer's 10 GB cap is achievable in-browser because Web Crypto plus chunked streaming keeps the whole path efficient.

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