Designing a Message Reactions System
A ground-up walkthrough of how Slack, iMessage, WhatsApp, Teams, Discord, LinkedIn and every modern messaging or feed platform lets millions of users tap a 👍, ❤️ or a custom emoji on a single message — and how that tiny tap is stored, counted, ordered, fanned out to every viewer in real-time, and reconciled across devices without lag, duplication or lost love.
The Big Idea, in One Breath
A message reactions system is the machinery that lets any viewer of any message attach one or more small, expressive tokens — emoji, stickers, custom guild reactions — to that message, and then makes sure the exact same aggregated view (“12 🔥, 4 😂, you reacted ❤️”) appears on every device, in every client, in real-time, forever.
It sounds small. It is not. Reactions are the single highest-volume write in most chat and feed platforms — often 5× to 20× the volume of the messages themselves. They demand real-time fanout, per-user personalisation, near-perfect ordering, and rock-solid idempotency — all while feeling weightless in the UI.
Imagine a giant auditorium where a speaker says one sentence and every listener holds up a coloured card — some green, some red, some hearts. The stage screen must show a live tally, updated the instant any card goes up or down, and every listener must also see what they voted. Now do that for a billion sentences a day, across every screen on Earth, with never a lost vote and never a phantom heart. A message reactions system is that live tally — but silent, in software, and running everywhere at once.
per message
visible everywhere
per-user consistent
What a Reactions System Really Is
Before designing one, we need to pin down what it does — and just as important, what it does not. A reactions system is not just a counter. It is a per-user, per-message, per-emoji graph with strict identity, real-time visibility and per-viewer personalisation.
2.1 A Working Definition
A message reactions system is a distributed component that, given a user, a message and a reaction token (standard emoji, custom emoji, sticker, or platform-specific reaction), guarantees:
- each (user, message, reaction) triple is idempotent — tapping twice does not double-count,
- the total count per reaction per message is eventually accurate and monotonically convergent,
- every viewer sees a personalised view: “you reacted ❤️” distinct from “12 people reacted ❤️,”
- updates fan out to every connected viewer within perceptual real-time,
- the system survives celebrity reactions storms, viral posts and coordinated pile-ons without collapse.
2.2 Where You Encounter It
Direct & Group Chats
iMessage tapbacks, WhatsApp reactions, Signal reactions — small groups, high per-message reaction density.
Team Collaboration
Slack, Teams, Discord — reactions replace whole conversations (“✅ means done”), custom emoji become team language.
Feeds & Posts
Facebook reactions, LinkedIn reactions, Instagram, YouTube likes — giant fanout, celebrity-scale reaction storms.
Live & Streams
Live streams, Twitch, TikTok live, Instagram Live — floating heart bursts, per-second aggregation, ephemeral state.
2.3 What It Is Not
A reactions system is not just an UPDATE count = count + 1 statement, and not just a set-of-user-IDs per emoji. It is the coordination layer that answers “who reacted, with what, when, and what does this viewer see right now?” — while surviving thundering herds and offline devices.
Think of reactions as a very lightweight, very high-volume secondary chat that runs in parallel to the main message stream. Every message becomes a tiny topic; every reaction is a tiny message; every viewer subscribes to every topic they can see. The reactions system is the pub/sub-and-materialise layer that turns those tiny messages into a single, personalised summary bar under every message.
Why It Matters So Much
Reactions look decorative. They are, in fact, one of the highest-leverage features a communication platform ships. They compress replies, signal emotional tone, drive engagement metrics and, at scale, dominate the write path.
3.1 The Business & Human Problem
- Engagement multiplier. A reaction is 20× cheaper than a reply, so users produce far more of them — they become the platform’s dominant engagement signal.
- Emotional bandwidth. Reactions carry tone that plain text loses. “Approved 👍” is not the same as “approved.” Platforms that get reactions wrong lose that bandwidth.
- Write-path dominance. On a busy channel or viral post, reactions arrive faster than messages. They are the peak-hour write load.
- Trust & correctness. Users notice a wrong count faster than a wrong message. “My heart disappeared” erodes trust immediately.
- Team language. Custom emoji become organisational vocabulary; broken custom emoji cause visible cultural friction, not just a technical bug.
3.2 What Makes It Uniquely Hard
Harder than a counter
- Every reaction is a (user, message, emoji) triple, not a bump.
- Per-viewer personalisation (“you reacted”) has to be efficient.
- Removal must be as fast and consistent as adding.
Harder than chat fanout
- Volume is 5–20× the underlying message rate.
- Bursts of thousands of reactions on one message in one second are normal.
- Custom emoji introduce a whole media-CDN sub-problem.
The reactions system exists so that a single tap on a single emoji feels instant, feels personal, and feels correct — even when a million other people are tapping the same emoji on the same message in the same second. Every design choice in this chapter serves that quiet promise.
The Building Blocks
A production reactions system is a small constellation of focused services. Each has one narrow job; the leverage comes from how they compose.
Reactions API
Idempotent write endpoint: PUT /messages/{id}/reactions/{emoji} and DELETE. Auth, rate-limit, validate emoji, dedup by (userId, messageId, emojiId).
Emoji Registry
Catalog of standard Unicode emoji and per-workspace custom emoji, with images/CDN URLs, aliases, deprecation state and access rules.
Reaction Log
Durable, ordered, append-only log of every add/remove event, partitioned by messageId. The source of truth for reconstruction and audit.
Per-Message Aggregator
Materialises the per-message reaction summary: {emoji: {count, sample_users[]}}. Updated as log events arrive; cached in a hot store.
Per-User Reaction Index
Materialises “which messages did user X react to and with what.” Powers the “you reacted” badge and cross-device sync.
Fanout Gateway
WebSocket / server-sent events fleet that pushes reaction deltas to every viewer of a message in real-time. Reuses the chat fanout fabric.
Read API & Personaliser
Returns the per-message summary combined with the “you” flag for the requesting user. Serves history/back-scroll pages.
Rate Limiter & Abuse Guard
Blocks reaction spam, coordinated pile-ons and bot storms. Per-user, per-tenant and per-message quotas.
Analytics Sink
Consumes the same reaction log to produce engagement metrics, sentiment aggregates and reporting dashboards.
Observability
Metrics (writes/s, fanout latency, aggregator lag, dedup ratio), traces per (userId, messageId), dashboards per tenant/region.
Data Model: How Reactions Are Actually Stored
Every subtle correctness bug in a reactions system traces back to the data model. The core tension is between two truths: reactions are per-user (identity matters) and reactions are per-message aggregates (counts matter). A good schema serves both without duplicating the state of the world.
5.1 The Canonical Triple
The atomic unit is the tuple (userId, messageId, emojiId). Everything else — counts, personalised badges, analytics — is a materialised view over the set of such tuples.
Reaction {
userId : uuid // who reacted
messageId : uuid // what they reacted to
emojiId : string // "thumbsup" (unicode) or "custom:acme:party_parrot"
action : "add" | "remove"
clientTs : timestamp // for tie-breaking & UX
serverTs : timestamp // authoritative
requestId : uuid // client-supplied idempotency key
}5.2 The Three Materialised Views
Per-Message Summary
Small, hot document keyed by messageId. Shape: { emojiId: { count, sample_userIds[] } }. Cached everywhere and served on message reads.
Per-User Index
Keyed by userId: which messages the user has reacted to, with which emoji. Powers cross-device sync and the “you reacted” flag.
Analytics Cube
Rolled-up event stream downstream: per-emoji, per-workspace, per-hour aggregates for reporting and ML.
5.3 Why Not Just UPDATE count = count + 1?
| Naive approach | What breaks | Right approach |
|---|---|---|
| Single counter column | No idempotency; retries double-count; no personalisation. | Store the tuple; count is derived. |
| List of userIds per emoji | Unbounded row size for viral messages. | Store count + bounded sample_userIds; full list is paged. |
| Client-driven counters | Every retry, network glitch or duplicate tap is a bug. | Server-authoritative log with request-ID idempotency. |
| Delete-on-remove without tombstone | Concurrent add/remove races produce phantom hearts. | Append remove event; aggregator resolves order. |
5.4 Storage Choices
- Reaction log — Kafka / Pulsar / Kinesis, partitioned by
messageId. Retention long enough to rebuild aggregates from scratch. - Per-message summary — low-latency KV (Redis, DynamoDB, ScyllaDB). Small document, high read volume.
- Per-user index — wide-column store (Cassandra, DynamoDB) keyed by
userId, clustered bymessageIddesc. - Emoji registry — small relational DB + CDN for images; edge-cached because it is on every render.
Never treat the summary as the source of truth. If Redis melts, you must be able to rebuild the summary by replaying the reaction log for the affected messages — and that operation should complete before the on-call finishes their coffee.
The Write Path: One Tap, End to End
The write path is where every hard property lives: idempotency, ordering, real-time visibility and fairness. Get the write path right and reads are just materialised views.
6.1 The Idempotency Contract
Every write carries a client-supplied requestId. The server dedups on (userId, messageId, emojiId, action, requestId) for a bounded TTL. This survives client retries, connection drops and duplicate taps.
Headers:
Authorization: Bearer <token>
Idempotency-Key: 018f1b23-... # client-generated per intent
Body:
{ "action": "add", "clientTs": 1723612345678 }
Responses:
200 OK { "summary": { ... }, "you": { "reactions": ["thumbsup"] } }
409 (rejected: emoji not allowed in this channel)
429 (rate limited: user-per-message quota exceeded)6.2 Server-Side Ordering
Add / remove events on the same (userId, messageId, emojiId) must be strictly ordered. The reactions API assigns a server timestamp on accept, and the log is partitioned by messageId so each message sees a single-writer view. Aggregators resolve concurrent add/remove using serverTs, with clientTs as a UX-only hint.
6.3 Handling the Reaction Storm
A viral message can receive thousands of reactions per second. The write path stays healthy via:
- Per-message micro-batching at the aggregator — coalesce 50 ms of events into one summary update.
- Sub-key sharding for the hottest messages — split
messageIdintoNshards and re-merge at read time. - Rate limits per user per message — a single user cannot tap 500 emoji on one message; the UI enforces a small maximum.
- Sampling of low-value telemetry during peaks — the aggregator ships full counts but samples analytics.
6.4 Removal, Not Just Addition
Add path
- Idempotent PUT.
- Append
addevent; aggregator increments if not already present. - Fanout delta to viewers.
Remove path
- Idempotent DELETE.
- Append
removetombstone; aggregator decrements only if the tuple existed. - Concurrent add/remove resolved by serverTs order.
The API never says “count is now N.” It always says “this user reacted” or “this user un-reacted.” The count is a derived, eventually-consistent view. Confusing the two is the source of every phantom-heart bug.
The Read & Fanout Path: Every Viewer, Personalised
Reads are dominated by two shapes: rendering a message with its current reaction bar, and receiving live deltas while looking at it. The system must serve both without doubling the storage cost or the socket cost.
7.1 The Personalised Summary
Every render of a message needs: the per-message summary and a per-user flag saying “you reacted with X.” The read API composes the two:
{
"messageId": "m_9f2a",
"summary": {
"thumbsup": { "count": 128, "sampleUsers": ["u_1","u_2","u_3"] },
"heart": { "count": 47, "sampleUsers": ["u_4"] },
"custom:acme:party_parrot": { "count": 12, "sampleUsers": [] }
},
"you": { "reactions": ["heart"] }
}7.2 Live Deltas Over the Socket
The fanout gateway pushes small deltas over the same WebSocket the chat client uses:
{
"type": "reaction.delta",
"messageId": "m_9f2a",
"emojiId": "thumbsup",
"action": "add",
"userId": "u_777", // only for the same viewer, else hashed
"newCount": 129,
"serverTs": 1723612345690
}7.3 What Gets Sent to Whom
| Viewer type | What they receive | Why |
|---|---|---|
| Owner of the reaction | Their own delta with full you flip | UI parity across devices |
| Other viewers of the message | Count delta + emoji, no you update | Personalised, cheap |
| Not currently viewing | No live delta; freshly rendered on next open | Save socket bandwidth |
| Bot / analytics | Log-tailed events, batched | Cheap, no real-time need |
7.4 Handling the “Firehose” Case
On a viral message, per-reaction deltas overwhelm socket bandwidth. The gateway degrades gracefully:
- Switch from per-event deltas to periodic snapshots (every 250 ms) once event rate exceeds a threshold.
- Collapse consecutive changes to the same
(messageId, emojiId)into a single delta. - Emit only aggregate counts to non-owner viewers during firehose mode.
End-to-End Flow: One Reaction’s Life
Enough abstraction. Let us follow one reaction — Ada taps ❤️ on a message in a 2,000-member Slack channel — from the moment her thumb leaves the screen to the moment every other viewer sees the count tick up.
Optimistic UI
Ada’s client immediately flips the heart to filled and increments the local count from 47 to 48. The tap is queued locally with a fresh requestId.
API accepts the write
Client sends PUT /messages/m_9f2a/reactions/heart. API validates auth, checks the emoji is allowed in the channel, checks per-user rate limits, assigns serverTs, and appends to the reaction log partition for m_9f2a. Returns 200 with the fresh summary.
Aggregator picks up the event
The per-message aggregator consuming m_9f2a’s log partition sees the add heart by u_ada event. It checks its idempotency cache, confirms this is new, and increments the summary’s heart.count to 48. It also updates sampleUsers if Ada is one of the first few.
Per-user index is written
A parallel consumer writes (u_ada, m_9f2a, heart) into Ada’s per-user reaction index so her other devices (laptop, tablet) will also render the heart on next sync.
Fanout gateway pushes deltas
The gateway looks up which viewers currently have m_9f2a on screen (subscribed to that channel and scrolled to that message). It pushes a reaction.delta event over each viewer’s socket.
Owner cross-device sync
Ada’s laptop, which is also connected, receives a delta marked with the you flag. Its local UI now shows “you reacted ❤️” without needing a re-fetch.
Analytics + audit
The log is tailed by the analytics pipeline, which increments per-emoji/per-workspace counters, and by the compliance sink, which writes an immutable audit record for enterprise tenants.
Later: back-scroll
A viewer scrolls up to that message hours later. The read API returns the persisted summary plus their own you flag — no replay required.
Quality Attributes: The “-ilities”
A reactions system is graded on unusual axes: it must be simultaneously ultra-low-latency, ultra-correct, and ultra-cheap per event — because there are so, so many events.
Reaction Latency
Median < 200 ms tap-to-visible on the reactor’s own device; P95 < 500 ms to every other online viewer.
Throughput
Comfortably absorbs 5–20× the platform’s message rate. Peak: hundreds of thousands of writes/second per region.
Idempotency
Client retries never double-count. Server-side dedup keyed on (userId, messageId, emojiId, requestId).
Convergence
Every viewer eventually sees the same count. Add/remove races resolve deterministically by serverTs.
Reliability
Aggregator can be rebuilt from the reaction log at any time. Zero silent data loss.
Scalability
Sharded by messageId. Hot messages sub-sharded automatically; aggregator scales on partition lag.
Availability
Graceful degradation: if fanout is down, writes still succeed and reads still work — late viewers just see the freshly-loaded summary.
Observability
Every write is traceable end-to-end. Aggregator lag, dedup ratio and firehose-mode activations are top-line SLOs.
9.1 The Latency Budget
| Hop | Target | How |
|---|---|---|
| Optimistic client update | < 16 ms | Pure local UI transition on tap |
| Client → API accept | < 60 ms | Edge PoP, keep-alive HTTPS, minimal payload |
| API log append | < 30 ms | Quorum ack in-region |
| Aggregator update | < 30 ms | Micro-batched, hot in-memory state |
| Fanout to online viewers | < 80 ms | Persistent socket, pre-serialised delta |
| Total to last online viewer | ~200–300 ms P95 | Feels weightless in the UI |
Common Pitfalls & Trade-offs
Every reactions system, in production, is bitten by the same handful of subtle bugs. Knowing them turns quarters of firefighting into a paragraph in a design review.
10.1 Ten Traps We’ve All Fallen Into
Counter-only storage
Storing just count per emoji makes idempotency impossible and per-user personalisation slow. Store the tuple; derive the count.
No idempotency key
Retries and duplicate taps double-count. Every write must carry a client-generated requestId, dedup at the API.
Client wins on order
Trusting clientTs for ordering means clock-skewed devices produce ghost reactions. Server timestamps are authoritative.
Unbounded sampleUsers list
Storing every reactor’s ID in the summary blows up hot rows. Cap the sample; page the full list via a separate endpoint.
Firehose kills the socket
Per-event deltas on a viral message flood every connected client. Switch to periodic snapshots and coalesced deltas under load.
Custom emoji CDN is single-region
Custom guild emoji load slowly for global users. Push emoji images to a multi-region CDN and pre-warm on upload.
Deleted messages retain reactions
Reactions on a purged message linger in indexes. Tombstone message deletion cascades to reactions storage and analytics.
Pile-on abuse
Coordinated reactions (mass 💩 or hate emoji) hit a single target. Rate-limit per user + per target + ML-side anomaly detection.
Optimistic UI lies
The client shows a heart, the server rejects (permissions), and the UI is stuck lying. Always reconcile on server ack and roll back visibly.
No back-fill for outages
The aggregator crashes; count freezes. Rebuild path from the log must be automated and covered by chaos drills.
10.2 The Trade-offs You Cannot Avoid
Latency vs Accuracy
- Optimistic UI feels instant but can lie briefly.
- Strict server-first UI is honest but noticeably slower.
- Every mature client picks optimistic + reconcile-on-ack, with visible rollback on rejection.
Personalisation vs Cost
- Per-viewer “you reacted” means a per-user index on top of per-message summary.
- Skipping it saves storage but breaks cross-device UX.
- The per-user index pays for itself on the first user complaint.
Context
We must decide whether the reaction storage is authoritative counters or authoritative tuples.
Decision
Adopt tuple-authoritative storage: the atomic unit is (userId, messageId, emojiId). All counters, sample lists and personalised badges are materialised views built by consuming an append-only reaction log. Client-supplied idempotency keys make every write safely retryable.
Consequences
Higher storage than a single counter column, but bounded and predictable. Zero double-counting, cheap cross-device sync, log-based rebuild path for the aggregator, and a clean substrate for analytics and moderation. This is the choice that makes every other correctness property possible.
How Reactions Systems Evolve
Reactions started as a decoration and grew into a first-class communication primitive. The evolution mirrors the industry’s slow discovery of just how load-bearing this “tiny” feature really is.
Wave 1 — Likes (2007–2015)
Single “like” button. One counter per post. No per-user detail, no removals in real-time. Facebook and Twitter live here for years.
Wave 2 — Multi-Emoji Reactions (2015–2018)
Facebook adds “love, haha, wow, sad, angry.” Slack ships arbitrary emoji reactions. Now the schema must be per-emoji, per-user — and the counter model shows its cracks.
Wave 3 — Custom Emoji (2018–2020)
Slack workspace emoji, Discord server emoji — per-tenant registries, CDN-backed images, permission checks. Reactions become team language.
Wave 4 — Real-Time Delta Fanout (2020–2023)
Reactions animate in real-time. Socket-based delta streams, firehose mode for viral messages, per-user cross-device sync become table stakes.
Wave 5 — AI-Aware Reactions (2024+)
Suggested reactions from LLMs; sentiment-weighted summaries (“mostly 🔥”); reactions as a training signal for personalisation; safety filters on custom emoji at upload time.
11.1 Adjacent Systems That Plug In
Search & Filter
“Show me messages I reacted to with 🔥” is powered by the per-user index feeding the search cluster.
Moderation
Reaction storms on flagged messages are a moderation signal; custom emoji uploads flow through image-safety classifiers.
Analytics & Growth
Reaction rate per post is a first-class engagement metric; emoji distribution informs UX experiments.
Personalisation & Ranking
The per-user reaction index is a strong signal for feed ranking, recommendations and “memory” features.
Key Takeaways
Reactions are a small feature that carries a very large system underneath. Every design choice should serve the illusion of a weightless, instant, always-correct tap.
Key Takeaways
- Reactions are tuples, not counters. Store
(userId, messageId, emojiId); derive everything else. - Idempotency is the foundation. Client-supplied request IDs make every retry safe and every duplicate tap harmless.
- Server owns time. Ordering, tie-breaking and convergence rely on server timestamps; client timestamps are UX hints only.
- The log is the source of truth. Every summary, index and analytics view is a materialisation over the append-only reaction log.
- Personalisation is not free. The per-user index is what makes cross-device UX work; skimp on it at your peril.
- Firehose gracefully. Viral messages demand periodic snapshots and coalesced deltas, not per-event floods.
- Custom emoji is a media problem. Multi-region CDN, safety scan on upload, permission-aware fetches — treat it seriously.
- Optimistic + reconcile-on-ack. Feels instant, stays honest, rolls back visibly on rejection.
- Abuse-aware from day one. Rate limits per user, per message and per target; ML anomaly detection for coordinated pile-ons.
- Rebuild is a first-class path. If aggregator state melts, replay from the log automatically; drill it quarterly.
The best reactions systems disappear. Users never think about the log, the aggregator, the sockets or the CDN. They just tap a heart, and everywhere on Earth, in the same breath, a small number ticks up by one. Every design decision in this chapter exists so that tiny, obvious moment remains tiny and obvious — even on the day a billion people all tap the same emoji at once.