Skip to content

About

Browser proving toolkit for Noir/bb.js: memory-aware worker pools, VK caching, and hard-won recipes for client-side proving

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

noir-web-prover

A toolkit for running Noir / bb.js (Barretenberg) proving in browsers without running out of memory.

Client-side proving with bb.js works, but the naive version falls over in production: WASM linear memory for a heavy UltraHonk circuit runs to hundreds of MB per worker, phones lie about their RAM, Chrome lies about core counts, and Worker.terminate() doesn't actually free Barretenberg's memory. This package is the plumbing that deals with all of that, extracted from a production app (Apertrue) that proves C2PA photo provenance in-browser.

Three pieces, usable independently:

  • prover-strategy — hardware detection and memory-budget tiers. Decides how many workers and threads a device can afford, with workarounds for navigator.deviceMemory capping at 8, Chrome's anti-fingerprinting core-count noise, and hybrid-CPU desktops that report 16 cores but can't sustain 16 threads of WASM.
  • ProverPool — a generic worker pool with queueing, per-job callbacks, budget enforcement, and a shutdown protocol that actually releases WASM memory. It is generic over your job input/result types and speaks a five-message protocol (protocol.ts) with a worker script you write.
  • VKCache — an IndexedDB cache for verification keys, which otherwise cost 2-5 s each to recompute on every page load. Versioned so you can invalidate per circuit.

Developed against @aztec/bb.js 4.1.x. The pool and cache don't import bb.js at all (your worker does), so other versions will likely work — but the memory numbers, the cleanup behavior, and the introspection recipe below were validated on 4.1.x only.

Install

npm install noir-web-prover

Quick start

1. Write a worker (this is where bb.js lives — the pool never touches it):

// prover.worker.ts
import { UltraHonkBackend } from '@aztec/bb.js';
import { Noir } from '@noir-lang/noir_js';
import type { PoolToWorkerMessage, WorkerToPoolMessage } from 'noir-web-prover';
import circuit from './my_circuit.json';

let noir: Noir | null = null;
let backend: UltraHonkBackend | null = null;

const post = (m: WorkerToPoolMessage<{ proof: Uint8Array; publicInputs: string[] }>) =>
  self.postMessage(m);

// IMPORTANT: register the handler synchronously at module top level.
// The pool posts 'init' immediately after `new Worker(...)` and relies on
// message queueing during module evaluation.
self.onmessage = async (e: MessageEvent<PoolToWorkerMessage<unknown, MyInput>>) => {
  const msg = e.data;
  switch (msg.type) {
    case 'init': {
      try {
        noir = new Noir(circuit);
        backend = new UltraHonkBackend(circuit.bytecode, { threads: msg.threadCount });
        post({ type: 'initialized', success: true });
      } catch (err) {
        post({ type: 'initialized', success: false, error: String(err) });
      }
      break;
    }
    case 'prove': {
      try {
        post({ type: 'progress', jobId: msg.jobId, progress: 10, message: 'Executing witness' });
        const { witness } = await noir!.execute(msg.input as any);
        post({ type: 'progress', jobId: msg.jobId, progress: 40, message: 'Proving' });
        const { proof, publicInputs } = await backend!.generateProof(witness);
        post({ type: 'result', jobId: msg.jobId, result: { proof, publicInputs } });
      } catch (err) {
        post({ type: 'error', jobId: msg.jobId, error: String(err) });
      }
      break;
    }
    case 'cleanup': {
      // Destroy Barretenberg so its WASM pthreads die BEFORE terminate().
      await backend?.destroy();
      backend = null;
      noir = null;
      post({ type: 'cleanup-complete' });
      break;
    }
  }
};

2. Run the pool from the main thread:

import { ProverPool } from 'noir-web-prover';

const pool = new ProverPool<MyInput, MyResult>({
  createWorker: () =>
    new Worker(new URL('./prover.worker.ts', import.meta.url), { type: 'module' }),
});

const jobId = await pool.submitJob(input, {
  onProgress: (job) => console.log(job.progress, job.message),
  onComplete: (job) => console.log('proof:', job.result),
  onError: (job) => console.error(job.error),
});

// When you're done proving for a while, give the memory back:
await pool.releaseWorkers();

Sizing is automatic (determinePoolConfig() from hardware detection), but you can pass config, cap workers with setMaxWorkersOverride(), and calibrate workerCostMB to your circuit's measured footprint so budget enforcement is honest.

3. Cache verification keys:

import { VKCache } from 'noir-web-prover';

const vkCache = new VKCache({
  dbName: 'myapp-vk-cache',
  // Bump a circuit's version when its bytecode changes; stale entries become misses.
  version: (circuitId) => ({ main: 'v3', aggregator: 'v1' })[circuitId] ?? 'v1',
});

let vk = await vkCache.get('main');
if (!vk) {
  vk = await backend.getVerificationKey();
  await vkCache.put('main', vk);
}

Why the cleanup protocol exists

Worker.terminate() looks like it should free everything. With multithreaded bb.js it doesn't: Barretenberg spawns WASM pthreads as nested sub-workers, and terminating the parent worker leaves them running, pinning the SharedArrayBuffer that backs the WASM linear memory — roughly 400 MB per proving worker, permanently, until the tab dies.

ProverPool.releaseWorkers() therefore posts { type: 'cleanup' } to each worker, waits (3 s timeout) for { type: 'cleanup-complete' } — your worker's cue to call backend.destroy(), which joins the pthreads — and only then calls terminate(). Implement the cleanup branch in your worker or you will leak the full WASM heap on every release cycle.

Recipes

COOP/COEP: cross-origin isolation is required for multithreading

Multithreaded bb.js needs SharedArrayBuffer, which browsers only expose on cross-origin-isolated pages. Serve your app with:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

(or credentialless for COEP if you embed third-party resources). Every subresource must then be same-origin or carry Cross-Origin-Resource-Policy / CORS headers — this is usually the painful part, and it's why the strategy module treats crossOriginIsolated === false as an automatic fall-back to single-threaded sequential proving rather than an error. Your app keeps working everywhere; it's just slower where isolation is missing.

Chrome's core-count lie and the SharedArrayBuffer gate

Two hardware-detection traps, both handled in prover-strategy.ts but worth knowing about:

  • Chrome's anti-fingerprinting can randomly report navigator.hardwareConcurrency = 2 on machines with 10+ real cores. If you tier devices on raw core count, a beefy desktop occasionally gets classified as a low-end phone. detectHardwareCapabilities() floors cores at 4 for this reason.
  • typeof SharedArrayBuffer !== 'undefined' is not sufficient; check crossOriginIsolated too. SAB can be defined but non-functional for cross-thread sharing when isolation is missing.

Also: navigator.deviceMemory caps at 8 in Chrome and doesn't exist in Firefox/Safari, so getMemoryBudgetMB() cross-checks it against core count and takes the more optimistic tier — otherwise every 64 GB workstation gets budgeted like an 8 GB laptop.

Self-hosting the CRS (structured reference string)

bb.js downloads CRS points (g1.dat, g2.dat) at runtime. For production you want these on your own infrastructure — the files are large (hundreds of MB for big circuits, fetched with Range requests) and you don't want your proving path to depend on a third-party CDN.

The pattern that works well: put the .dat files in an R2/S3 bucket behind a CDN domain, and if your workers were ever pointed at an app-relative path like /api/crs/..., keep a tiny redirect shim there — validate the filename against an allowlist (g1.dat, g2.dat, bn254_g1.dat, ...) and return a 302 to the CDN URL. The redirect keeps old cached worker bundles working while letting the actual bytes come from the CDN, and it sidesteps serverless response-size/timeout limits that a proxying route would hit on multi-hundred-MB files. Make sure the CDN serves CORS headers and supports Range requests.

Self-hosting the bb.js WASM

Same story for the WASM binaries (barretenberg.wasm.gz / threaded variants): serve them yourself, same-origin if possible. Under COEP, cross-origin WASM fetches need CORS/CORP headers; same-origin sidesteps that class of failure entirely. Serve with application/wasm (or correct Content-Encoding: gzip for the .gz artifacts) so streaming compilation works. If you bundle the worker, check that your bundler copies the WASM assets and that the URLs bb.js resolves at runtime point where you think they do — this is the most common "works in dev, 404s in prod" failure.

Live WASM memory introspection

There's no public API for "how much memory is Barretenberg using", but on bb.js 4.1.x you can reach the WebAssembly.Memory through internals:

// api is the Barretenberg (AsyncApi) instance
const wasm = (api as any)?.backend?.wasm;         // AsyncApi -> BarretenbergWasmAsyncBackend -> BarretenbergWasmMain
const bytes = wasm?.getMemory?.().buffer.byteLength;
const memoryMB = bytes ? Math.round(bytes / (1024 * 1024)) : null;

Report this in your worker's progress/result messages (memoryMB field) and the pool tracks per-worker peaks for you (getPeakMemoryMB()). Two caveats: it's an internal chain and can break on any bb.js upgrade (hence the ?. at every step), and it measures virtual linear-memory allocation, which is much larger than physical RSS — the OS only backs pages that were actually written. Use it for trend/leak detection, not as an absolute "RAM used" number.

The dual-bb.js-version Pedersen trap

Different bb.js major lines produce different Pedersen hashes for the same inputs (generator/domain-separation changes — e.g. 0.63.x vs 3.x/4.x disagree). If any Pedersen (or Poseidon-via-bb) hash you compute in JS is persisted, committed on-chain, or must match a hash computed inside a circuit, pin one bb.js version for every place that computes it and treat upgrades as a migration, not a bump. This bites in the exact scenario dependency dedup creates: one part of your app resolves bb.js 4.x while an SDK transitively pulls an old 0.x, both "work", and hashes silently disagree. Check npm ls @aztec/bb.js and make sure hash-persistent paths all go through the same copy.

Two-phase batches (prove then aggregate)

If your pipeline proves N inputs and then recursively aggregates them, use computeWorkerAllocation(batchSize) to plan both phases up front. It returns how many proving workers to run, how many aggregation workers may overlap with the prove phase (0 on mid/low-tier devices — running both worker types simultaneously is the classic OOM), and how many aggregation workers to scale to after releaseWorkers() frees the prove-phase memory. Run two ProverPool instances (different worker scripts, different workerCostMB) and hand each the relevant count via setMaxWorkersOverride().

Apple platforms

Proving with Barretenberg on iOS/macOS via Swift has its own set of traps (shared backend corruption, proving-key reuse, QoS scheduling of FFI work). See docs/apple-platforms.md.

License

Apache-2.0. Extracted from production code at Apertrue.

About

Browser proving toolkit for Noir/bb.js: memory-aware worker pools, VK caching, and hard-won recipes for client-side proving

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages