Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Faster Encryption (Multi-Threaded Proving)

Generating the ZK proof of knowledge is by far the most expensive part of encryptInputs — typically 90%+ of the total time. TFHE's WASM build is compiled with rayon, so that proof can be computed across several threads instead of one.

The SDK turns this on automatically when the browser allows it. Whether it's allowed is decided entirely by your page's HTTP headers, so this is something an app opts into by how it's served.

What it's worth

Measured in Chromium on a 12-core machine — median of 5 proofs packing a uint128, uint64, and uint32:

ThreadsProof timeSpeedup
1 (default)3199 ms1.00×
21687 ms1.90×
4964 ms3.32×
6728 ms4.39×
8657 ms4.87×
12624 ms5.13×

Roughly 5× faster proving, turning a multi-second encryption into well under a second. Starting the thread pool costs about 37 ms, once, on first encryption.

The gains flatten past 8 threads, so 'auto' caps there rather than using every core — each thread is a real Web Worker competing with your app for CPU.

The requirement: cross-origin isolation

Threads share TFHE's WASM memory, and browsers only permit sharing memory between threads in a cross-origin isolated context. That needs two response headers on the document:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless

Without them, self.crossOriginIsolated is false, SharedArrayBuffer is unavailable, and the SDK silently stays single-threaded. Encryption still works exactly as before — you just don't get the speedup. There is nothing to catch or handle.

Trade-offs to check first

Cross-Origin-Opener-Policy: same-origin breaks cross-origin popups

This is the one most likely to bite a dApp. COOP same-origin severs the window.opener relationship with cross-origin popups. If your app depends on postMessage to or from a popup on another origin — some wallet connectors, social logins, and OAuth flows do — that communication will break.

same-origin-allow-popups preserves popups but does not enable cross-origin isolation. You cannot have both. If your wallet flow relies on cross-origin popup messaging, leave the headers off.

Wallet connections that use WebSockets, browser extensions, or deep links (including most WalletConnect setups) are unaffected.

Cross-Origin-Embedder-Policy restricts embedded resources

Both values require cross-origin subresources to opt in, and cross-origin iframes (wallet widgets, price charts, analytics embeds) must send COEP themselves or they'll be blocked.

ValueCross-origin subresourcesBrowser support
credentiallessLoad without credentials — no cooperation neededChrome/Edge 96+, Firefox 119+, not Safari
require-corpMust send Cross-Origin-Resource-Policy or CORSAll isolation-capable browsers

credentialless is usually the pragmatic choice: third-party images, fonts, and CDN assets keep working without the other origin changing anything. The cost is that Safari doesn't support it, so Safari users fall back to single-threaded proving. Use require-corp if you need Safari and control (or can vouch for) every cross-origin resource you load.

Setting the headers

Vite

Headers must be set for both the dev server and vite preview — they're separate config blocks, and missing the second makes production builds look mysteriously slower than dev.

vite.config.ts
import { defineConfig } from 'vite';
 
const crossOriginIsolation = {
  'Cross-Origin-Embedder-Policy': 'credentialless',
  'Cross-Origin-Opener-Policy': 'same-origin',
};
 
export default defineConfig({
  optimizeDeps: { exclude: ['tfhe'] },
  worker: { format: 'es' },
  server: { headers: crossOriginIsolation },
  preview: { headers: crossOriginIsolation },
});

Next.js

next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          { key: 'Cross-Origin-Embedder-Policy', value: 'credentialless' },
          { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' },
        ],
      },
    ];
  },
};

Vercel

vercel.json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Cross-Origin-Embedder-Policy", "value": "credentialless" },
        { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }
      ]
    }
  ]
}

nginx

add_header Cross-Origin-Embedder-Policy credentialless always;
add_header Cross-Origin-Opener-Policy   same-origin      always;

Verifying it worked

In the browser console:

crossOriginIsolated; // must be true

If that's true, the next encryption starts the thread pool. The SDK's proof worker logs which path it took:

[Worker] TFHE initialized (rayon thread pool: 8 threads)

or, when isolation is missing:

[Worker] TFHE initialized (single-threaded: not cross-origin isolated — serve the page with …)

If the main thread generates proofs itself (useWorkers: false, or the worker failed and the SDK fell back), you can read its pool status programmatically. With workers on, the main thread never starts a pool, so this stays null:

import { getTfheThreadPoolStatus } from '@cofhe/sdk/web';
 
// null until the main thread has had to generate a proof
const status = getTfheThreadPoolStatus();
// { enabled: true, threads: 8 }
// or { enabled: false, threads: 1, reason: '…why it bailed' }

The most direct signal is the Prove step duration in .onStep(...) dropping several-fold.

Tuning with tfheThreads

tfheThreads on CofheConfig controls the thread count. It's web-only and ignored on Node/Hardhat.

import { createCofheConfig } from '@cofhe/sdk/web';
import { chains } from '@cofhe/sdk/chains';
 
const config = createCofheConfig({
  supportedChains: [chains.sepolia],
 
  // 'auto'  → navigator.hardwareConcurrency, capped at 8 (default)
  // number  → request exactly this many threads
  // false   → stay single-threaded
  tfheThreads: 'auto',
});
ValueBehaviour
'auto'Default. navigator.hardwareConcurrency, capped at 8
a numberExactly that many threads, ignoring the cap
falseNever start a pool — proving stays on one thread

Lowering it (for example tfheThreads: 4) leaves more CPU for the rest of your app during encryption. Setting false opts out entirely.

Notes

  • Proving already runs off the main thread. With useWorkers: true (the default), proofs are generated in a Web Worker regardless, so your UI never blocks. Threading makes that work finish sooner; it doesn't change what blocks.
  • No fallback needed. Non-isolated pages, Safari with credentialless, single-core devices, and thread-pool startup failures all degrade to single-threaded proving automatically.
  • Node is unaffected. @cofhe/sdk/node uses node-tfhe, which has no browser thread pool.