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.

20260909113223_47ed85.png

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.

20260909113223_c3ffce.png

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.

20260909113223_46f004.png

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.

20260909113223_668124.png

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:

  1. Per-channel: full-screen serial, banner parallel, combo merged — different gifts take different roads.

  2. Full-screen channel: priority queue + max-wait timeout, premium gifts first and never dropped.

  3. Banner channel: semaphore-capped on-screen concurrency, waiting queue with a limit.

  4. Combo channel: merge into one ticking number, time-window aggregation + debounced animation.

  5. Backpressure drop: tiered by value under backlog — drop the right thing.

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

Related assets

Happy Anniversary Celebration Gift - CustomizableClutch Finish MomentGuardian Aries