Designing a Message Reactions System

Designing a Message Reactions System

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.

01

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.

Analogy

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.

10×
Reaction writes
per message
< 200 ms
Reaction to
visible everywhere
100%
Idempotent &
per-user consistent
02

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

Chat

Direct & Group Chats

iMessage tapbacks, WhatsApp reactions, Signal reactions — small groups, high per-message reaction density.

Work

Team Collaboration

Slack, Teams, Discord — reactions replace whole conversations (“✅ means done”), custom emoji become team language.

Social

Feeds & Posts

Facebook reactions, LinkedIn reactions, Instagram, YouTube likes — giant fanout, celebrity-scale reaction storms.

Live

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.

💡
Mental Model

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.

03

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 Core Motivation

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.

04

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.

4.1

Reactions API

Idempotent write endpoint: PUT /messages/{id}/reactions/{emoji} and DELETE. Auth, rate-limit, validate emoji, dedup by (userId, messageId, emojiId).

4.2

Emoji Registry

Catalog of standard Unicode emoji and per-workspace custom emoji, with images/CDN URLs, aliases, deprecation state and access rules.

4.3

Reaction Log

Durable, ordered, append-only log of every add/remove event, partitioned by messageId. The source of truth for reconstruction and audit.

4.4

Per-Message Aggregator

Materialises the per-message reaction summary: {emoji: {count, sample_users[]}}. Updated as log events arrive; cached in a hot store.

4.5

Per-User Reaction Index

Materialises “which messages did user X react to and with what.” Powers the “you reacted” badge and cross-device sync.

4.6

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.

4.7

Read API & Personaliser

Returns the per-message summary combined with the “you” flag for the requesting user. Serves history/back-scroll pages.

4.8

Rate Limiter & Abuse Guard

Blocks reaction spam, coordinated pile-ons and bot storms. Per-user, per-tenant and per-message quotas.

4.9

Analytics Sink

Consumes the same reaction log to produce engagement metrics, sentiment aggregates and reporting dashboards.

4.10

Observability

Metrics (writes/s, fanout latency, aggregator lag, dedup ratio), traces per (userId, messageId), dashboards per tenant/region.

05

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.

Core reaction event
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

V1

Per-Message Summary

Small, hot document keyed by messageId. Shape: { emojiId: { count, sample_userIds[] } }. Cached everywhere and served on message reads.

V2

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.

V3

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 approachWhat breaksRight approach
Single counter columnNo idempotency; retries double-count; no personalisation.Store the tuple; count is derived.
List of userIds per emojiUnbounded row size for viral messages.Store count + bounded sample_userIds; full list is paged.
Client-driven countersEvery retry, network glitch or duplicate tap is a bug.Server-authoritative log with request-ID idempotency.
Delete-on-remove without tombstoneConcurrent 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 by messageId desc.
  • Emoji registry — small relational DB + CDN for images; edge-cached because it is on every render.
💡
Design Note

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.

06

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.

PUT /messages/{id}/reactions/{emoji}
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 messageId into N shards 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 add event; aggregator increments if not already present.
  • Fanout delta to viewers.

Remove path

  • Idempotent DELETE.
  • Append remove tombstone; aggregator decrements only if the tuple existed.
  • Concurrent add/remove resolved by serverTs order.
i
Contract Note

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.

07

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:

GET /messages/{id}/reactions?viewer={userId}
{
  "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:

reaction.delta
{
  "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 typeWhat they receiveWhy
Owner of the reactionTheir own delta with full you flipUI parity across devices
Other viewers of the messageCount delta + emoji, no you updatePersonalised, cheap
Not currently viewingNo live delta; freshly rendered on next openSave socket bandwidth
Bot / analyticsLog-tailed events, batchedCheap, 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.
“A reaction feels weightless because five services conspire, in the background, to make one tap look effortless to a million viewers.”
08

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.

1

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.

2

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.

3

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.

4

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.

5

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.

6

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.

7

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.

8

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.

09

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.

Perf

Reaction Latency

Median < 200 ms tap-to-visible on the reactor’s own device; P95 < 500 ms to every other online viewer.

Perf

Throughput

Comfortably absorbs 5–20× the platform’s message rate. Peak: hundreds of thousands of writes/second per region.

Corr

Idempotency

Client retries never double-count. Server-side dedup keyed on (userId, messageId, emojiId, requestId).

Corr

Convergence

Every viewer eventually sees the same count. Add/remove races resolve deterministically by serverTs.

Rely

Reliability

Aggregator can be rebuilt from the reaction log at any time. Zero silent data loss.

Scal

Scalability

Sharded by messageId. Hot messages sub-sharded automatically; aggregator scales on partition lag.

Avail

Availability

Graceful degradation: if fanout is down, writes still succeed and reads still work — late viewers just see the freshly-loaded summary.

Observ

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

HopTargetHow
Optimistic client update< 16 msPure local UI transition on tap
Client → API accept< 60 msEdge PoP, keep-alive HTTPS, minimal payload
API log append< 30 msQuorum ack in-region
Aggregator update< 30 msMicro-batched, hot in-memory state
Fanout to online viewers< 80 msPersistent socket, pre-serialised delta
Total to last online viewer~200–300 ms P95Feels weightless in the UI
“A reaction that lags is worse than no reaction. The count must move at the speed of feeling, not the speed of the network.”
10

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

1

Counter-only storage

Storing just count per emoji makes idempotency impossible and per-user personalisation slow. Store the tuple; derive the count.

2

No idempotency key

Retries and duplicate taps double-count. Every write must carry a client-generated requestId, dedup at the API.

3

Client wins on order

Trusting clientTs for ordering means clock-skewed devices produce ghost reactions. Server timestamps are authoritative.

4

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.

5

Firehose kills the socket

Per-event deltas on a viral message flood every connected client. Switch to periodic snapshots and coalesced deltas under load.

6

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.

7

Deleted messages retain reactions

Reactions on a purged message linger in indexes. Tombstone message deletion cascades to reactions storage and analytics.

8

Pile-on abuse

Coordinated reactions (mass 💩 or hate emoji) hit a single target. Rate-limit per user + per target + ML-side anomaly detection.

9

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.

10

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.
ADR-01Accepted
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.

11

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.

1

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.

2

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.

3

Wave 3 — Custom Emoji (2018–2020)

Slack workspace emoji, Discord server emoji — per-tenant registries, CDN-backed images, permission checks. Reactions become team language.

4

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.

5

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

Search & Filter

“Show me messages I reacted to with 🔥” is powered by the per-user index feeding the search cluster.

Mod

Moderation

Reaction storms on flagged messages are a moderation signal; custom emoji uploads flow through image-safety classifiers.

Anlyt

Analytics & Growth

Reaction rate per post is a first-class engagement metric; emoji distribution informs UX experiments.

Rec

Personalisation & Ranking

The per-user reaction index is a strong signal for feed ranking, recommendations and “memory” features.

12

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.
i
Closing Thought

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.