Counters, Not Logs: Likes and Comments at Live-Stream Scale
A live show inverts the normal read/write ratio. For twenty minutes, thousands of people write to the same few rows — one video's like count, one stream's comment list, one giveaway document. The engineering is making sure the moment the audience arrives is not the moment the database becomes the bottleneck.
The load
- Spiky. Nothing, then a merchant goes live and writes jump by orders of magnitude.
- Convergent. Every viewer touches the same video and the same stream. No natural sharding by user.
- Individually worthless. Nobody will ask which tap took the count from 4,102 to 4,103.
One database write per like loses on all three. That third property is the opening: if only the total matters, a like doesn't have to be an event you store. It can be a number you move.
Likes: the write path
A tap is one atomic in-memory increment and nothing else — both commands O(1) against a single key, no row inserted, no document locked, no like log:
HINCRBY likes:{videoId} delta 1 // the count
SADD dirty:likes {videoId} // "this one moved"
A worker drains the dirty set on an interval and folds each accumulated delta into the durable counter with one write per video:
for (const videoId of await redis.sPop('dirty:likes', BATCH)) {
const delta = Number(await redis.hGet(`likes:${videoId}`, 'delta'));
if (!delta) continue;
await ShoppableFeed.updateOne({ _id: videoId }, { $inc: { like_count: delta } });
await redis.hIncrBy(`likes:${videoId}`, 'delta', -delta); // not DEL
}
Ten thousand likes become one database write. A spike now costs Redis memory — one integer per live video — instead of write throughput, which is finite and shared.
Three details carry the correctness:
- Decrement by exactly what was flushed. Likes keep arriving during the flush.
DELor reset-to-zero would silently drop them;hIncrBy(key, -delta)subtracts only what was persisted. - Increments commute.
+1then+1is order-independent, so deltas can be batched, split or retried without changing the answer. Storing who liked when would forfeit that — the real reason the per-like log is gone. - Read from Redis, not Mongo. Otherwise a viewer taps, nothing moves for a full interval, and they tap again. Mongo's
like_countis the durable floor, one flush behind.
The trade: the durable count is stale by up to one interval, and a Redis loss between flushes loses that window. Fine for a vanity counter, wrong for anything transactional. It also means "has this user liked this?" needs its own membership structure rather than falling out of the write path.
Everything else in the layer
Comments are high-volume and append-only, so they get the same treatment. Each stream buffers in memory and flushes every 5 seconds as one insertMany(..., { ordered: false }) — one write per stream per interval instead of one per comment, and a single malformed document can't take the batch down with it. Teardown flushes before disconnecting, so the tail of the chat survives stream end.
Everything else — giveaway entry, poll votes, live bids — is lock-free by construction. No read-modify-write, no transaction, no lock. One atomic conditional write, and the live count comes from that write's own returned snapshot:
const doc = await Giveaway.findOneAndUpdate(
{ _id: giveawayId, status: 'open', entrants: { $ne: userId } },
{ $addToSet: { entrants: userId } },
{ returnDocument: 'after' }
);
const entrantCount = doc?.entrants.length; // no follow-up read
The filter carries the dedup and the eligibility check into the same round trip as the write; the count then comes free from the document that write already returned. Two failure modes disappear together — no second query racing the first, so no viewer sees a count that was stale before it was read, and no contended document to serialise on.
Fan-out rides ZEGO custom room commands, the same room infrastructure already carrying the video. Every viewer is in the room by definition, so broadcasting the new count is one call to a provider we already depend on — and there is no websocket tier of our own to run, scale, or get paged about.
One contended document is the only thing that turns a viewer spike into an outage. So the design's job is to make sure there isn't one.
The two lines
| Impact | Mechanism |
|---|---|
The engagement layer stays responsive under stream-scale concurrency, and a viewer spike costs Redis memory instead of database write capacity — per-like writes collapse into one increment per video per flush, per-comment writes into one insertMany per stream per 5 s. |
A like is one atomic HINCRBY plus a dirty-set marker, drained by a worker that decrements by exactly what it flushed; every other interaction is a single conditional write whose returned snapshot supplies the count, fanned out over ZEGO custom room commands. |
What generalises
- Ask what the durable record is for. A per-like log is a cost paid on the hottest path for data nobody queries. Deleting it beat any optimisation of keeping it.
- Commutativity is a licence to batch. Aggregation is safe only because increments fold in any order. Without that property, no amount of buffering saves you.
- Prefer one conditional write to a read plus a write. The filter carries the precondition, the return value carries the read. The race stops existing rather than getting handled.