High-concurrency real-time mobile applications—such as multi-seat voice party rooms (YO3 Party Live, Vola Party) and live bidding marketplaces (BidToBill)—require separating the low-latency UDP/WebRTC media plane from the transactional WebSocket signaling plane, bounding full-screen Lottie/Rive gift animations in a FIFO recycle pool, and enforcing server-authoritative transactional locks on wallet deductions and bid acceptances.
- Decouple continuous audio/video media streams (WebRTC) from room state machines and seat locks (WebSockets).
- Prevent Out-Of-Memory (OOM) crashes in live gifting rooms by queuing and recycling vector animation controllers.
- Use heartbeat leases and sequence timestamps to recover cleanly from cellular network handoffs.
1. Separating the Media Plane from the Transactional Signaling Plane
In social entertainment apps such as YO3 Party Live and Vola Party, up to 16 hosts speak at once while hundreds of listeners scroll chat and send full-screen virtual gifts. If audio, seat state, chat and wallet deductions all travel through one pipeline, a burst of chat messages can delay a microphone mute, and a retried gift can charge a user twice.
These workloads want opposite things. Audio tolerates loss but not delay: a dropped packet is a click nobody notices, while a late packet is useless. Money tolerates delay but not loss: a wallet deduction can take 200ms as long as it happens exactly once.
So we split the system into two planes. The media plane carries audio and video over WebRTC, which runs on UDP and handles echo cancellation, jitter buffering and voice-activity levels. The signaling plane carries everything with business meaning over WebSockets: who holds which seat, what was said in chat, which gift was sent, and what each wallet now contains.
- Media plane (WebRTC): Opus audio frames, video tracks, speaking indicators. Lossy, low latency, never touches the database.
- Signaling plane (WebSocket): seat locks, chat, gift events, coin ledger updates. Ordered, acknowledged, backed by transactions.
- The only link between them is identity: the signaling plane decides who may publish audio, and the media server enforces it.
3. Gifts and Coin Wallets: Exactly-Once Money
A virtual gift is a payment. The sender's coin balance goes down, the host's earnings go up, and the room sees an animation. Mobile networks make this dangerous, because a client that did not hear back will retry, and a naive server will charge twice.
Every gift request carries an idempotency key generated on the device. The server records the key in the same transaction as the ledger entries. A retry with the same key finds the existing record and returns the original result without touching the balance again.
The balance check and the deduction happen in one statement, so a wallet can never go below zero even when a user sends gifts from two devices at once. The room broadcast goes out only after the transaction commits. An animation for a gift that was never paid for cannot happen.
export async function sendGift(input: {
idempotencyKey: string;
senderId: string;
hostId: string;
giftId: string;
price: number;
}) {
return db.transaction(async (tx) => {
const existing = await tx.query(
"SELECT id FROM gift_transfers WHERE idempotency_key = $1",
[input.idempotencyKey]
);
if (existing.rowCount > 0) return { duplicate: true };
// Deduct only if the balance covers it. Zero rows means "not enough coins".
const debit = await tx.query(
`UPDATE wallets SET balance = balance - $1
WHERE user_id = $2 AND balance >= $1`,
[input.price, input.senderId]
);
if (debit.rowCount === 0) throw new InsufficientCoinsError();
await tx.query(
"UPDATE wallets SET earnings = earnings + $1 WHERE user_id = $2",
[input.price, input.hostId]
);
await tx.query(
`INSERT INTO gift_transfers
(idempotency_key, sender_id, host_id, gift_id, price)
VALUES ($1, $2, $3, $4, $5)`,
[input.idempotencyKey, input.senderId, input.hostId, input.giftId, input.price]
);
return { duplicate: false };
});
}4. Keeping Gift Animations from Crashing the Room
Full-screen gift effects are heavy assets. In a busy room, twenty gifts can arrive in a few seconds. Playing all of them at once means twenty decoded animations in memory, dropped frames, and on older Android phones an out-of-memory crash in the middle of a live show.
The client treats gift playback as a queue with a fixed number of slots. Incoming gifts wait in first-in-first-out order. A slot plays one animation, releases its controller when the animation completes, and takes the next gift from the queue. Repeated gifts from the same sender are merged into a combo counter instead of queueing the same animation ten times.
class GiftAnimationQueue {
GiftAnimationQueue({this.maxConcurrent = 2, this.maxQueued = 30});
final int maxConcurrent;
final int maxQueued;
final Queue<GiftEvent> _pending = Queue<GiftEvent>();
int _playing = 0;
void enqueue(GiftEvent gift) {
// Merge repeats into a combo instead of queueing duplicates.
final last = _pending.isEmpty ? null : _pending.last;
if (last != null && last.isSameGiftFrom(gift)) {
last.comboCount += 1;
return;
}
// Under extreme load, drop the oldest small gift, never a premium one.
if (_pending.length >= maxQueued) _dropLowestPriority();
_pending.add(gift);
_pump();
}
void _pump() {
while (_playing < maxConcurrent && _pending.isNotEmpty) {
final gift = _pending.removeFirst();
_playing++;
play(gift).whenComplete(() {
_playing--; // Controller disposed inside play(); slot is free again.
_pump();
});
}
}
}Memory use is now bounded by maxConcurrent, not by how generous the audience is. Combined with a RepaintBoundary around the animation layer, the room holds 60fps for gifts and video together.
5. Surviving Network Handoffs with Heartbeats and Sequence Numbers
Phones change networks constantly. A user walks out of Wi-Fi range, the socket dies silently, and for a few seconds the server still shows them in a seat while the room moves on without them.
Two mechanisms handle this. The first is a heartbeat lease. Each client pings every few seconds, and the server holds a seat only while the lease is fresh. If pings stop, the lease expires and the seat is released automatically. No stuck ghost hosts.
The second is a sequence number on every room event. The client remembers the last sequence it applied. On reconnect it sends that number, and the server either replays the missed events or, if the gap is too large, sends a fresh snapshot of the room. The client never has to guess what it missed.
- Events are applied in sequence order; a duplicate or out-of-order event is ignored.
- A small gap is filled by replaying missed events from a short server-side buffer.
- A large gap triggers a full snapshot: seats, speaker list, pinned message, wallet balance.
- The media connection restarts independently, so audio can recover before or after room state does.
6. Eliminating Race Conditions in Sub-Second Service Bidding (BidToBill)
The same ideas carry over to a very different product. In BidToBill, built for an Australian client, a customer posts a local service job and nearby providers submit competing bids that appear live, broadcast in under 500ms.
The dangerous moment is acceptance. The buyer taps "Accept" on one bid while another provider is submitting a lower one. Both requests are valid on their own. Only one outcome can be allowed.
Acceptance runs as a single database transaction. It locks the job row, checks that the job is still open, marks the winning bid, rejects the others, and creates the invoice record. A late bid that arrives during that transaction waits on the lock, then finds the job closed and is refused with a clear message. Only after the commit does the server broadcast the result to the room.
BEGIN;
-- Lock the job so concurrent accepts and late bids must wait.
SELECT status FROM jobs WHERE id = :job_id FOR UPDATE;
-- Application checks status = 'open'; otherwise ROLLBACK.
UPDATE bids SET status = 'accepted'
WHERE id = :bid_id AND job_id = :job_id AND status = 'pending';
UPDATE bids SET status = 'rejected'
WHERE job_id = :job_id AND id <> :bid_id AND status = 'pending';
UPDATE jobs SET status = 'awarded', awarded_bid_id = :bid_id
WHERE id = :job_id;
INSERT INTO invoices (job_id, bid_id, buyer_id, provider_id, amount)
SELECT j.id, b.id, j.buyer_id, b.provider_id, b.amount
FROM jobs j JOIN bids b ON b.id = :bid_id
WHERE j.id = :job_id;
COMMIT;Because the invoice is created inside the same transaction as the award, there is never an accepted bid without a bill, or a bill without an accepted bid.
7. A Short Checklist for Any Real-Time Mobile Product
Voice rooms and bidding rooms look different on screen, but the engineering rules are the same, and they apply to delivery tracking, multiplayer features and live auctions as well.
- Separate what may be lost (media) from what must not be (state and money).
- Make the server the only authority on shared state; clients request and render.
- Give every state change a sequence number and every payment an idempotency key.
- Broadcast after the transaction commits, never before.
- Bound every client-side queue: animations, chat history, pending events.
- Test on a throttled connection and by toggling airplane mode. That is where real users live.
These patterns are how the YO3 suite keeps voice room latency under 150ms across three shipped apps. If you are planning a live streaming, voice or bidding product, see our real-time and live streaming app service and the backend engineering that sits behind it. The order-splitting side of marketplace transactions is covered in our multi-vendor marketplace article.
More Engineering Insights
Book a Free Strategy Call
Speak directly with Principal Software Architect Abrar Ali on architecture, timeline, and fixed-scope delivery.
