Live Gift Queues and Concurrency Control: How the Client Enqueues, Batches, and Drops When Everyone Sends at Once
How should a live streaming client handle hundreds of gifts arriving at once? This article explains client-side queuing, batching, priority control, and drop strategies that keep gift effects smooth without overwhelming memory or freezing low-end devices.

The earlier posts covered client-side runtime optimization, asset delivery and preloading, and the backend side — message push, deduplication, and idempotency. Those solve the problem of how a single gift message is reliably produced, delivered, and made ready. But in a real live room, gifts never arrive one at a time. During a top streamer’s PK moment, hundreds of users hammer the send button in the same second — combos, full-screen blockbusters, and small banner gifts all pouring into the client at once.
If you render one the moment it arrives, there’s only one outcome: effects interrupt each other, memory explodes, and low-end devices freeze outright. This post is about client-side queueing and concurrency control — once gift messages reach the device, how do you enqueue them, batch them, and know when to decisively drop them, keeping the room lively without melting the client.

Cloud Stairway to the Heavens: a scene-scale full-screen effect that owns the whole screen and can only play one at a time — the textbook case for a serial queue (XingJi Shop)
1. Why you must have a queue
First, think through what happens without one. Say a room at peak sends the client 50 gift messages per second — a mix of full-screen blockbusters (rockets, sports cars), combo gifts (heart ×N), and ordinary banner gifts.
If you render each one the instant it lands:
Full-screen effects interrupt each other. The first rocket animation is 0.3s in and the second arrives; the screen flickers between them and the user sees nothing clearly.
Memory explodes. Fifty effect instances created at once — SVGA/VAP decoders, textures, and bitmaps all piling into memory. Low-end devices OOM immediately.
The main thread stalls. Every message triggers a layout and render pass, saturating the main thread and dropping frames on the live video itself.
So the client needs a layer of buffering plus scheduling that turns a chaotic message stream into a paced, controllable playback sequence. That’s the value of the queue.
2. Per-channel queues: different gifts take different roads
The first key design decision: don’t put every gift in one big queue. Different gift types follow completely different playback rules, and cramming them into one queue only makes them interfere with each other.
Split into three independent channels by display form:
public class GiftDispatcher {
// Full-screen blockbusters: own the screen, one at a time, serial
private final SerialGiftChannel fullScreenChannel;
// Banner gifts: can run in parallel, but cap on-screen count
private final ParallelGiftChannel bannerChannel;
// Combo gifts: handled separately, merged rather than queued
private final ComboGiftChannel comboChannel; public void onGiftReceived(GiftMessage msg) {
switch (msg.getDisplayType()) {
case FULL_SCREEN:
fullScreenChannel.enqueue(msg);
break;
case BANNER:
bannerChannel.enqueue(msg);
break;
case COMBO:
comboChannel.accept(msg);
break;
}
}
}Each channel minds its own business:
Full-screen channel: rockets, sports cars, planets — big effects that own the whole screen. Must be serial, one finishes before the next plays.
Banner channel: ordinary gifts drifting across from one side of the screen. Can run in parallel, but with a cap on how many are on screen at once.
Combo channel: repeated taps by the same user on the same gift, merged into one ticking number rather than queued and played N times.

Tycoon Entrance: a high-value full-screen entrance gift that must jump the priority queue and never get stuck behind cheap gifts (XingJi Shop)
3. Full-screen channel: serial queue + priority + max wait
Full-screen effects are the scarcest resource — only one can be on screen at any moment. So its core is a priority serial queue.
Priority ordering
A high-value gift (the whale’s rocket) can’t be stuck behind a pile of cheap gifts and play minutes later — that’s a fatal experience for a paying user. Use a priority queue ordered by gift value:
public class SerialGiftChannel {
private final PriorityBlockingQueue<GiftMessage> queue =
new PriorityBlockingQueue<>(64, (a, b) -> {
// higher value first; same value by arrival order
if (a.getPrice() != b.getPrice()) {
return Integer.compare(b.getPrice(), a.getPrice());
}
return Long.compare(a.getArriveTime(), b.getArriveTime());
});private volatile boolean playing = false;
public void enqueue(GiftMessage msg) {
msg.setArriveTime(System.currentTimeMillis());
queue.offer(msg);
tryPlayNext();
}
private synchronized void tryPlayNext() {
if (playing || queue.isEmpty()) return;
GiftMessage next = queue.poll();
playing = true;
playFullScreen(next, () -> {
// finished: release resources, play the next
playing = false;
tryPlayNext();
});
}
}Max wait: expire and drop
At peak the queue may back up with dozens of full-screen gifts. If each one has to play through, the last might wait several minutes — by then the user has long swiped away and playing it is pointless.
Give each message a max wait time and drop it if it times out in the queue:
private synchronized void tryPlayNext() {
if (playing) return;
long now = System.currentTimeMillis();
GiftMessage next;
// drop gifts that have waited past the limit
while ((next = queue.poll()) != null) {
if (now - next.getArriveTime() > MAX_WAIT_MS) { // e.g. 15s
logDiscard(next, "wait_timeout");
continue;
}
break;
}
if (next == null) return;
playing = true;
playFullScreen(next, () -> {
playing = false;
tryPlayNext();
});
}There’s a trade-off here: premium gifts shouldn’t be dropped even on timeout. Exempt top-tier gifts from the timeout, or greatly extend their wait limit, to protect the paying experience.
4. Banner channel: parallel + on-screen cap
Banner gifts (ordinary gifts drifting across the screen edge) can coexist, but not without limit. Dozens drifting at once turn the screen to mush and the GPU can’t keep up.
Use a semaphore to cap concurrency:
public class ParallelGiftChannel {
private static final int MAX_CONCURRENT = 5; // at most 5 banners on screen
private final Semaphore slots = new Semaphore(MAX_CONCURRENT);
private final Queue<GiftMessage> waiting = new ConcurrentLinkedQueue<>(); public void enqueue(GiftMessage msg) {
waiting.offer(msg);
drain();
}
private void drain() {
while (slots.tryAcquire()) {
GiftMessage msg = waiting.poll();
if (msg == null) {
slots.release(); // nothing waiting, hand the permit back
return;
}
playBanner(msg, () -> {
slots.release(); // a banner finished, free the slot
drain(); // try the next in the waiting queue
});
}
}
}The waiting queue needs a cap too — over it, drop the oldest banner. Banners are low value, so dropping one barely dents the experience.

Gemini Eternal Necklace: a low-cost, high-frequency gift — merged into a single ticking counter instead of stacking N animations (XingJi Shop)
5. Combo channel: merge, don’t queue
Combos are where the client is easiest to blow up. A user hammers the heart gift and dozens of combo messages arrive per second. Enqueue and render each one and the queue explodes instantly.
The core of combo handling is merging: messages with the same senderId + giftId + comboId merge into one combo effect, maintaining a single ticking number (×1 → ×2 → ×66) instead of stacking 66 animations.
public class ComboGiftChannel {
// key = senderId + giftId + comboId
private final Map<String, ComboView> activeCombos = new ConcurrentHashMap<>(); public void accept(GiftMessage msg) {
String key = msg.getSenderId() + "_" + msg.getGiftId() + "_" + msg.getComboId();
ComboView view = activeCombos.computeIfAbsent(key, k -> {
ComboView v = new ComboView(msg);
v.show(); // first appearance, create the combo bubble
return v;
});
// already exists: just update the number, no new animation
view.updateCount(msg.getComboCount());
view.resetExpireTimer(COMBO_IDLE_MS); // collapse after e.g. 3s idle
}
}Paired with two techniques:
Time-window aggregation: batch combos within 200ms and update the number once, rather than refreshing the UI per message.
Debounced animation: drive the combo number’s tick with debounce/throttle to avoid saturating the main thread with high-frequency redraws.
In one line: a combo is visually one continuously ticking effect, but in data it’s an aggregation of a stream of messages — never map them one to one.

Gilded Feather Dream: the priciest, longest top-tier effect on the store — the one backpressure must protect first, dropping a hundred cheap gifts before it (XingJi Shop)
6. Backpressure and drop policy: how to degrade gracefully when you can’t keep up
Even with channels split, extreme spam can still produce messages faster than the client consumes them. That’s when you need backpressure — dropping proactively rather than fighting on until you crash.
Tiered drop, protecting from low value up to high:
public void onGiftReceived(GiftMessage msg) {
// pending backlog over threshold, trigger dropping
if (totalPending() > BACKPRESSURE_THRESHOLD) {
if (msg.getPrice() < LOW_VALUE_THRESHOLD) {
// drop low-value gifts, keep only an aggregate count
aggregateDiscardCount(msg);
return;
}
// high-value gift: clear the banner channel to make room
bannerChannel.dropOldest();
}
dispatch(msg);
}Low-value gifts (hearts, likes): drop on backlog, replace with an aggregate hint like “99+ people just sent hearts.”
Mid-value gifts: throttle — over the on-screen cap they wait, and time out if the wait is too long.
High-value gifts: always prioritized; clear low-value effects to free resources when needed.
The core principle: dropping is inevitable; what matters is dropping the right thing. Better to drop a hundred hearts than to let one rocket stall or vanish.
7. Lifecycle and memory: release cleanly when playback ends
Once the queue is running, memory is the easiest place to plant a landmine. If an effect doesn’t fully release when it finishes, a user parked in the room for tens of minutes will eventually OOM.
When each effect finishes, the callback must do four things:
private void onEffectFinished(GiftView view) {
view.removeFromParent(); // 1. remove the view
view.releasePlayer(); // 2. return the player instance to the pool
view.recycleBitmaps(); // 3. free decoded bitmaps/textures
view.clearListeners(); // 4. unbind all listeners, prevent leaks
}Paired with a player object pool to avoid high-frequency new/destroy churn:
public class PlayerPool {
private final Queue<SvgaPlayer> idle = new ConcurrentLinkedQueue<>(); public SvgaPlayer acquire() {
SvgaPlayer p = idle.poll();
return p != null ? p : new SvgaPlayer();
}
public void release(SvgaPlayer p) {
p.reset(); // clear current animation state
idle.offer(p); // return to the pool for reuse
}
}Before shipping, always run a memory leak test: park in a room and spam gifts for half an hour, watching the memory curve. Any sustained climb that never falls back means an effect isn’t releasing cleanly.
8. Metrics to watch after launch
The effect of queueing and concurrency control is validated by data:
Gift render success rate = rendered on screen / valid gift messages received. The core experience metric.
Drop rate (layered by gift value): low-value drops are acceptable; high-value drop rate must approach zero, and any rise should alarm immediately.
Combo merge rate = merged effect count / raw combo message count. Reflects whether combo handling is working.
Full-screen queue average wait time: too long means severe peak backlog — consider more aggressive timeout dropping.
In-room memory growth curve: an indirect signal of whether release is clean.
void trackGiftResult(GiftMessage msg, String result) {
tracker.track("gift_render", Map.of(
"giftId", msg.getGiftId(),
"price", msg.getPrice(),
"result", result, // rendered / discarded / merged
"channel", msg.getDisplayType(),
"queueWait", msg.getQueueWaitMs()));
}Wrapping up
When everyone sends at once, the core designs of client-side queueing and concurrency control:
Per-channel: full-screen serial, banner parallel, combo merged — different gifts take different roads.
Full-screen channel: priority queue + max-wait timeout, premium gifts first and never dropped.
Banner channel: semaphore-capped on-screen concurrency, waiting queue with a limit.
Combo channel: merge into one ticking number, time-window aggregation + debounced animation.
Backpressure drop: tiered by value under backlog — drop the right thing.
Lifecycle: four-step cleanup on finish + player object pool, guard hard against OOM.
One line runs through all of it: the gift stream is uncontrollable, but the client’s playback pace must stay controllable. The essence of the queue is a buffer — one that can sort, merge, and drop — inserted between a surging message stream and finite on-device resources.
With that, the gift-effect pipeline closes the loop: produced on the backend, delivered over CDN, made ready on the client, and finally played back with rhythm.
Last updated:



