# Balance sync between products: show another product’s live balance over WebSocket

> When product A moves a user’s balance into product B and A’s app shows B’s balance, polling through A’s backend ties A’s capacity to B’s latency. How B pushing each change over WebSocket fixes it, with flowcharts.

Source: https://hibira.io/topics/balance-sync-websocket
Updated: 2026-11-09

A common setup between two products: a user moves part of their balance from product A into product B, then uses it in B. A's app still has to show how much is left in B, live, while the user spends or wins there. The first version usually asks for it over HTTP: A's app polls A's backend, and A's backend asks B. Every poll becomes a chain of requests across both products, and as traffic grows that chain, not either product, limits how many users you can serve.

## How request chains build up

```text
flow: Before: A's app asks A's backend, and A's backend asks B, for every user, every few seconds
== Moving the balance
A's app --POST /transfer--> A's backend|waits for B --POST /deposit--> B's backend
== Showing B's balance in A's app
A's app|every 5 s --GET /b-balance--> A's backend|waits for B --GET /balance--> B's backend|one worker per request
A's app|every 5 s --GET /b-balance--> A's backend|waits for B --GET /balance--> B's backend|one worker per request
```

Every HTTP request holds something while it waits: a worker thread, a connection from the client's pool, often a database connection. A's request cannot answer until B has, so it holds all of that for its own work plus the whole time B takes. [Little's law](https://en.wikipedia.org/wiki/Little%27s_law) puts a number on it: requests in progress = requests per second × seconds each one takes. And B receives a request for every A user who has the page open, every few seconds, whether the balance changed or not.

## What it costs in workers

A worked example, not a benchmark (written 2026-11-09): 5,000 of A's users have B's balance on screen and A's app polls every 5 seconds, so 1,000 requests a second go through A to B.

| | Polling through A's backend | B pushes each change over WebSocket |
| --- | --- | --- |
| Balance requests a second reaching A and B | 1,000 each, most of them unchanged | none; one read when the app connects |
| Time each request holds a worker on A | 10 ms of its own work + a 60 ms call to B = 70 ms | nothing passes through A |
| A's workers busy relaying B's balance | 1,000 × 0.07 s = 70 | 0 |
| The same when B slows to 600 ms | 1,000 × 0.61 s = 610, three times a pool of 200 | still 0: the next update just arrives later |

The last row is where it hurts. With a pool of 200 workers, a slow moment in B is enough to use up A's: A's own requests, logins and payments included, wait in the queue, then time out, then clients retry and add more requests. A's concurrency falls at the moment traffic peaks, because of a product A does not run. B, meanwhile, spends its own capacity answering polls that mostly say nothing changed.

## After: B publishes each change once

```text
flow: After: B publishes every change to the user's balance, and A's app receives it directly
A's app --POST /transfer--> A's backend --POST /deposit--> B's backend|writes B's ledger
B's backend|after each change --publish balance.updated--> *Hibira|private-b-balance-42 --WebSocket--> A's app|and B's own app
```

```text
sequence: The user moves 1,000 into B, then spends 120 there; A's app sees both at once
A's app | A's backend | B's backend | *Hibira
1 > 2: Move 1,000 to B
2 > 3: POST /deposit | idempotency key
3 >> 2: 200 | B balance 1,000, version 1
3 > 4: balance.updated | private-b-balance-42
4 > 1: balance.updated | 1,000, version 1
note: Later the user spends 120 in B
3 > 4: balance.updated | 880, version 2
4 > 1: balance.updated | 880, version 2
```

B makes one publish after each change it writes, the transfer in included. A's app holds one WebSocket connection and receives the new balance in the time of one network trip. Nothing about B's balance passes through A's backend any more, so a slow or busy B cannot hold A's workers, and B no longer answers thousands of polls.

## How to set it up

- **B owns B's balance.** B's ledger is the source of truth for what is left in B. After each commit, B publishes `balance.updated` with the new balance and a version number, using the HTTP API of any Pusher server library.
- **One private channel per user.** For example `private-b-balance-42`. If A and B use the same app, A's auth endpoint signs the subscription after checking that the signed-in user is 42. If B runs its own app, A's app connects with B's app key and B's auth endpoint signs it. Use `private-encrypted-b-balance-42` if the relay should only ever see ciphertext; other private channels are protected by TLS in transit and encrypted at rest.
- **Use the version number.** Ignore an event whose version is lower than the one on screen. When A's app connects or reconnects, read B's balance once over HTTP, then apply events.
- **A's backend can listen too.** If A needs B's balance on the server, for example before it lets the user move money back, it can keep a copy updated from the same events with a Pusher client library, instead of calling B each time.

## What stays the same

The transfer from A to B is still an HTTP request with an idempotency key, so a retry never moves the money twice, and B still checks the balance whenever it writes. WebSocket carries the news that B's balance changed, not the change itself. When the products on the other side are outside API providers, the transfer itself can also flood queues: see [wallet transfers between a platform and API providers](https://hibira.io/topics/wallet-transfers-api-providers).

## Common questions

### Why not just poll less often?

A longer interval means fewer requests but an older balance: a win in B shows up in A's app a whole interval later, and every poll still waits on B. Pushing a change takes one network trip, and nothing is sent while nothing changes.

### What if A's app misses an event while it is disconnected?

pusher-js and the other client libraries reconnect on their own. After reconnecting, read B's balance once over HTTP and apply only events with a higher version. The version number also makes duplicate and late events harmless.

### Does A have to change B's code?

B needs one addition: publish `balance.updated` after it writes a balance, and sign subscriptions if it runs its own app. Its transfer API stays as it is.

### How are these events counted on Hibira?

Each publish counts as one message. On paid plans the deliveries to subscribers are not counted, so a balance shown in both A's app and B's costs the same as one shown in one. See [pricing](https://hibira.io/pricing).
