The first sync
The hardest user-experience problem in putting Railgun in a browser tab, and how Grail cut it from 25 minutes and 2.4 GB of memory to a few minutes that run in the background.
The problem
A Railgun wallet holds no balance on chain. Its funds are encrypted notes in a public Merkle tree, and only the owner's viewing key can find them. That is the source of the privacy, and it has a price: to know its balance and to build a proof, a wallet needs a local copy of the tree. Nobody else can do this for it without learning what it owns.
A desktop wallet can spend a while building that copy once. A launchpad cannot, because someone who sees a new token wants to buy it now. In a browser, on mainnet, the first sync had three problems:
| Problem | Measured |
|---|---|
| The quick-sync page timed out | The indexer took up to a minute for a page of 10,000 commitments, and the SDK allowed 20 seconds. The first sync of any new mainnet wallet failed and left an empty balance |
| It was slow | About 25 minutes, measured in Node with the timeout raised |
| It was too heavy | A peak of 2.4 GB of memory, more than a browser tab can count on |
We raised the timeout first, with a small patch to the SDK that runs at install and fails loudly if the SDK's code changes. That made the sync work, not usable.
The idea
Most of the work in a first sync is the same for everyone: fetching and storing Railgun's tree. That data is public, and it holds nothing specific to a user. Only the last step, trying to decrypt notes with a viewing key, is private. So the shared part can be done once on a server, and each browser can start from the result and do the private part itself.
The browser still scans for its own notes locally and still builds every proof locally. The server never learns which notes belong to whom, and it never sees a key.
How it works
On the server
- 1An hourly job brings an engine database up to date with the same SDK that wallets use.
- 2It exports only three public namespaces:
merkletree-erc20,railgun-transaction-idsandchain_sync_info. Wallet entries are never exported. - 3It writes length-prefixed key and value records into gzip parts of 64 MiB of raw data, about 12 MB compressed, with a JSON manifest that lists each part, its size and its entry count.
- 4It keeps the last three snapshots, so a download in progress survives the next export.
[u32 keyLength][u32 valueLength][key][value] ...The version keys in chain_sync_info matter more than they look. Without them the engine concludes that its data is from an old version, wipes both trees and resyncs from zero. On 2026-11-04 the mainnet snapshot had 1.86 million entries in 20 parts, 241 MB in total.
In the browser
- 1Before the engine starts, the app reads the manifest and streams each part through
DecompressionStreaminto the IndexedDB store the engine will open. - 2It writes each finished part and the progress record in one transaction, so a reload or a dropped connection resumes at the next part.
- 3When the last part is in, the engine starts and finds a tree only about an hour old. It syncs the rest from the chain as usual and scans for the wallet's notes from its creation block.
Why these details
| Detail | Why |
|---|---|
| Parts, not one file | A single 241 MB download kept being reset after four to five minutes, for curl as well as the browser. Parts of about 12 MB avoid that and make resuming simple |
| Retry forever on a network error | Falling back to a full sync would cost 25 minutes and 2.4 GB, so a dropped connection just waits, up to 30 seconds between tries, and says it is retrying |
| A clear message for a full disk | Quota errors stop the import and tell the user to free about 1 GB |
| Entry counts per part | A part that was cut short is detected and fetched again |
| One tab at a time | A Web Lock lets one tab run the engine. Another tab says so and takes over when the first closes |
| The engine lives at app level | Setup keeps running while the user moves between pages, and the header shows its progress |
What the user can do meanwhile
The address appears in about 3 seconds, derived directly from the phrase. The recovery phrase and deposits are available at once. People can browse tokens and deposit during setup, then come back to a ready balance. Withdrawals and private trades wait for the engine, because those need the tree.
Results on mainnet
| Step | Result |
|---|---|
| First open, all 20 parts over the internet | 325 to 600 seconds in our runs, against about 25 minutes for a full sync |
| Opening again in the same browser | 8 seconds |
| First deposit | Confirmed at 2% of the download |
| Moving between pages during setup | No interruption, the download went from 3% to 15% |
| Reload at 25% | Back at 20% with no password, then moving again |
| About 90 seconds offline | Showed that it was retrying at 30%, then continued |
The browser database ends up at about 600 MB. These are measurements from desktop Chrome on one machine, so slower disks and connections will take longer.
Trust and what is left
The snapshot is a cache of public data, so it holds no secrets, and leaking it costs nothing. The contracts accept a proof only against a Merkle root they already know, and spending needs the user's keys, so a bad snapshot cannot spend anyone's funds. It could still make a wallet miss notes or fail to build proofs until it is replaced.
Today the import checks the network and the entry counts, and it does not yet compare the imported root with the contract's root history. That check is the next step, along with:
- A smaller snapshot for new wallets, which can never own old notes and do not need to decrypt them.
- Testing on phones and low-end machines.
Grail is an independent project built on Railgun's public contracts and SDK. Railgun contributors do not run, review or endorse Grail, and they did not write this documentation. Their own documentation is at docs.railgun.org.