bitcoin-kernel / browser-node
Validating the chain from genesis — proof-of-work, signatures, no double-spends — in a tab, with 32 bytes of state.
Open a browser tab and validate Bitcoin in it. Not query an explorer, not trust a server — actually check the proof-of-work, verify the signatures, enforce the no-double-spend rule, from the genesis block forward.
Four people will tell you this is impossible, and all four are right:
This is the story of beating each one — and of the small, beautiful piece of arithmetic that beats the hardest of them.
The spark was an ordinary question: can we sync testnet4 fast? Modern Bitcoin Core can, using
assumeUTXO — you load a snapshot of the UTXO set at some height, get a usable node in minutes, and validate
the history underneath in the background. The snapshot is just a file. Files can be torrented. So the first
experiment was mundane: download a Core UTXO snapshot over BitTorrent, loadtxoutset, done in a few
minutes instead of hours.
Then the actual idea: WebTorrent runs in a browser. A tab can pull that snapshot peer-to-peer over WebRTC, no server in the middle. And there's a pure-JavaScript implementation of Bitcoin's consensus rules — the bitcoin-kernel engine — that runs anywhere JS does. Put those together and the absurd premise has a shape: bootstrap a node from a torrented snapshot, in a tab.
Each capability is a small answer to an obvious objection.
But how do you hold the coins? A single JavaScript Map throws a tantrum past ~16.7 million
entries, so the coin view is sharded across 64 maps. Lookups land in under 40 nanoseconds — fast enough
that the UTXO set is never the bottleneck during validation.
But how do you actually validate a block? You hand the engine the block plus a coin view and it runs the real rules — prevout resolution, fees, coinbase maturity, the witness commitment, and the scripts: every signature, checked. To prove it isn't rubber-stamping, flip a single satoshi in one coin's value and the BIP143 signature it commits to fails. The block is rejected. That's the whole game: a forgery has nowhere to hide.
But how do you follow the chain? You apply each block to the coin view — remove the coins it spends, add the ones it creates — so the next block can spend them. Run it over a stretch of real blocks and watch the UTXO set evolve, every signature verified along the way.
But a tab can't open TCP. Right. So a ~40-line bridge relays the raw Bitcoin p2p protocol between a WebSocket (which the tab speaks) and a real peer (which speaks TCP). The tab does the version handshake, asks for headers, and validates the entire header chain itself — proof-of-work, the testnet4 difficulty rules, linkage, reorgs. The bridge is pure plumbing: it can withhold data, but it cannot forge a valid header, because the tab does the checking. With it, the tab reached the live tip — and caught blocks that were mined while it was running.
But that bridge only ran on localhost. At first, yes. Then it learned to speak WebRTC: the tab and the bridge meet in a room on a signaling server — a JavaScript Solid Server pod — then connect directly, NAT-traversed and encrypted, with no open port and no certificate. So the bridge can live anywhere, and the live feed works the moment a stranger clicks the link, signaled entirely through someone's pod. The same WebRTC that delivers the snapshot now carries the p2p stream too. (A separate page just follows the live tip — validating each new header as testnet4 mines it.)
But the snapshot is in Bitcoin Core's format. So you write a parser for it — Core's dumptxoutset
v2 layout, with its compressed amounts and script encodings. The proof that the parser is correct is delicious:
feed a real block's real signatures against coins decoded from the file, and they verify. A segwit signature
commits to the amount and the script, so if a single byte of the decompression were wrong, the signatures would
fail. They pass. The torrented Core snapshot is now a working coin view in the tab.
But it forgets everything on reload. So it persists to OPFS, the browser's origin-private file system. The header chain is checkpointed to disk; a reload resumes from disk in under two seconds instead of re-syncing 140,000 headers from genesis.
But JavaScript is slow at crypto. So you drop in a WebAssembly build of libsecp256k1 — but only behind a gate: it must agree with the pure-JS verifier on every test vector before it's trusted. It does, and it's several times faster.
But heavy validation freezes the tab. So the whole engine — validation, the coin view, the WASM crypto, the disk I/O — moves into a Web Worker. The same work that locks the UI for a full second on the main thread runs in the worker with the page staying perfectly responsive.
Piece by piece, the tab became a node: bootstrap, validate, follow, sync live, persist, accelerate. And then it hit a wall that no amount of engineering gets through.
To validate the chain forward to the present, you need the present UTXO set. So: load the real one, all 14 million coins, and measure honestly what it costs in memory.
It didn't even fit in Node with a 24 GB heap — it crashed. A browser tab gets a few gigabytes at most. There is no clever encoding that turns 25 GB into something a tab can hold; you can shave it, but not by an order of magnitude. Holding the full UTXO set in a tab is simply not on the menu.
This is the honest moment in the project. Everything up to here was engineering. This was a wall.
Here is the idea that walks through the wall, and it's the kind of thing that makes you grin. It comes from a 2025 proposal called SwiftSync, and it rests on a single line of arithmetic:
every output ever created − every input ever spent = the UTXO set
Read it again, because it's the whole trick. Every coin that gets created is eventually spent — except the ones that make up the current UTXO set. So if you take the set of all outputs and subtract the set of all inputs, everything that was created-then-spent cancels, and what survives is exactly the unspent coins.
Now do that subtraction with a hash instead of a giant table. Keep one running number. As you stream the chain block by block, add a hash of every output created and subtract a hash of every input spent. A coin that's created and later spent is added once and subtracted once — it vanishes. What's left at the end is a fingerprint of precisely the coins still unspent: the UTXO set, distilled to a constant 32 bytes.
And it's not a trust shortcut. If a single block tried to spend a coin that never existed, or spend one twice, the sums wouldn't cancel — the final number wouldn't match the known commitment, and validation fails. The hints that make it fast (a tiny file marking which outputs survive — about 25 bytes per block, a few megabytes for the whole chain) carry no authority either: wrong hints just make the check fail. They can't make a bad chain look good.
So the demo does the thing the wall said was impossible. It streams blocks from the genesis block forward, in the tab, in a worker, feeding each output and input into the accumulator — and verifies the result against an independently computed commitment. The whole early chain checks out holding nothing but those 32 bytes. The rest of the chain is the same loop; it's just more data to stream (and most of that data is inscription witness bytes the accumulator doesn't even need).
Be honest about the edges. The live feed needs that little bridge — a tab will never speak raw TCP, and that bridge can eclipse you even though it can't forge anything. assumeUTXO trusts a snapshot until the history beneath it is checked. And the full genesis-to-tip stream is bound by bandwidth, not by cleverness — it's the one lap left to run end to end in a tab.
But every load-bearing piece is built, measured, and live: real consensus validation, a real Core snapshot parsed and validated against, a live peer feed, persistence, WASM crypto, a worker for scale, and — the one that matters most — validation that doesn't need the 25 GB set at all. Thirteen small demonstrations, one running-node dashboard, and a from-genesis run, in a 3.8 MB repository where the largest file is the libsecp256k1 binary.
dumptxoutset · ⑦⑧ persist to OPFS ·
⑨ WASM secp256k1 · ⑩ run it in a Worker · ⑪ SwiftSync, stateless · ⑫ the full set in 32 bytes ·
⑬ hints → reconstruct → verify
The point was never a product. It was to find out whether a Bitcoin full node in a browser tab is a contradiction in terms — and to answer it honestly, with running code and real numbers. It isn't a contradiction. The hardest wall came down to a subtraction.
@bitcoin-desktop/schema consensus engine. AGPL-3.0.