← Topics

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.

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

Moving the balance

A's app
POST /transfer
A's backendwaits for B
POST /deposit
B's backend

Showing B's balance in A's app

A's appevery 5 s
GET /b-balance
A's backendwaits for B
GET /balance
B's backendone worker per request
A's appevery 5 s
GET /b-balance
A's backendwaits for B
GET /balance
B's backendone worker per request
Before: A's app asks A's backend, and A's backend asks B, for every user, every few seconds

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 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.

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

A's app
POST /transfer
A's backend
POST /deposit
B's backendwrites B's ledger
B's backendafter each change
publish balance.updated
Hibiraprivate-b-balance-42
WebSocket
A's appand B's own app
After: B publishes every change to the user's balance, and A's app receives it directly
A's appA's backendB's backendHibiraMove 1,000 to BPOST /depositidempotency key200B balance 1,000, version 1balance.updatedprivate-b-balance-42balance.updated1,000, version 1Later the user spends 120 in Bbalance.updated880, version 2balance.updated880, version 2
The user moves 1,000 into B, then spends 120 there; A's app sees both at once

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.

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.

Product names are trademarks of their owners.