> For AI agents: the documentation index is at https://sdk.nimbusprivacy.xyz/llms.txt, and every page is available as Markdown at its URL with .md appended.

# Sync (/helpers/sync)

The private balance lives on the user's device. `syncNoteStore` brings it up to date with Solana
and Nimbus's indexer. [Why the device syncs](https://sdk.nimbusprivacy.xyz/explainers/sync) explains the reason.

Every payment syncs before it starts. Between payments, syncing is up to you. In this guide,
Alice's app syncs when she opens it, then every 15 seconds while the tab is visible.

## Sync once

```ts
import { syncNoteStore } from "@umbra/nimbus-solana-web";

const result = await syncNoteStore(session, {});

switch (result.kind) {
  case "ingested":
    console.log(`Synced through slot ${result.slot}.`, result.caughtUp);
    break;
  case "unregistered":
    console.log("Register first. There is nothing to sync yet.");
    break;
  case "unavailable":
    console.log(`Stopped at ${result.step}. The next sync carries on.`);
    break;
}
```

## The result

| `kind`         | What happened                                                           | What to do                                                    |
| -------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- |
| `ingested`     | The device took in everything through `slot`, in `rounds` rounds.       | Read the balance again.                                       |
| `unregistered` | Alice has not registered, so there is nothing to read.                  | [Register](https://sdk.nimbusprivacy.xyz/registration) first. |
| `unavailable`  | The device's storage refused a step, or another tab kept writing to it. | Sync again later. Nothing taken in is lost.                   |

`unavailable` also carries `step`, the step that stopped, and `detail`. Both are for your logs.

A sync throws a `NimbusSdkError` when it cannot go on. The reason is `service-unavailable` when the
indexer or the RPC cannot be reached. It is `unreadable` when either answers something that cannot
be read.

## What caughtUp means

A sync runs in rounds. Each round reads one page of everything new, up to `pageRows` rows.

`caughtUp` is `true` when the last round reached the end of everything new. It is `false` only when
`maxRounds` ran out first. Sync again to read the rest.

## Options

| Option       | Default  | What it does                                                     |
| ------------ | -------- | ---------------------------------------------------------------- |
| `maxRounds`  | no limit | The most rounds to run. A whole number of at least 1.            |
| `pageRows`   | `1000`   | The rows asked for in each page, from 1 to 1000.                 |
| `signal`     | none     | An `AbortSignal` that stops the sync between rounds.             |
| `onProgress` | none     | Called after each round with its `round`, `slot` and `caughtUp`. |

A `maxRounds` or `pageRows` outside its range throws a `NimbusSdkError` with reason
`misconfigured`, before anything is fetched.

When the signal aborts, the sync stops between rounds. It rejects with the signal's reason, and
what earlier rounds took in stays.

## Sync in the background

This recipe syncs every 15 seconds while the tab is visible. It reads a few rounds at a time, never
runs two syncs at once, and returns a function that stops it:

```ts
import { syncNoteStore } from "@umbra/nimbus-solana-web";

function startBackgroundSync(session: NimbusSession): () => void {
  const controller = new AbortController();
  let syncing = false;

  const tick = async (): Promise<void> => {
    if (syncing || document.visibilityState !== "visible") return;
    syncing = true;
    try {
      const result = await syncNoteStore(
        session,
        {},
        { maxRounds: 3, signal: controller.signal },
      );
      if (result.kind === "ingested") await refreshBalances();
    } catch (thrown) {
      if (!controller.signal.aborted) console.error(thrown);
    } finally {
      syncing = false;
    }
  };

  void tick();
  const timer = setInterval(() => void tick(), 15_000);

  return () => {
    clearInterval(timer);
    controller.abort();
  };
}
```

Call the stop function before you [close the session](https://sdk.nimbusprivacy.xyz/reference/sessions#closenimbussession). A
sync that is still running then stops at the end of its round.

## What a sync also does

- **It frees stopped payments.** A payment whose tab closed before it sent anything holds part of
  the balance. A sync gives it back. See [Reserved funds](https://sdk.nimbusprivacy.xyz/helpers/reserved-funds).
- **It serves every tab.** Each tab of your app on the same site reads the same device storage. A
  sync in one tab updates the balance for all of them.

Two tabs syncing at once is safe, but wasted work. A step that keeps losing to the other tab
returns `unavailable`, and the next sync finishes it.

## Next

- [Balances](https://sdk.nimbusprivacy.xyz/helpers/balances): Read what the sync took in.
- [Why the device syncs](https://sdk.nimbusprivacy.xyz/explainers/sync): Where the private balance lives.
- [Reserved funds](https://sdk.nimbusprivacy.xyz/helpers/reserved-funds): Free what a stopped payment holds.
- [API reference](https://sdk.nimbusprivacy.xyz/reference/reads#syncnotestore): syncNoteStore: its options and what it returns.


Last updated 21 October 2026
