cf.completefrontend
My progress
SYSTEM DESIGN · #48

Design a like button for millions of users

The heart under every post: one tap flips it, the count moves by one, and it must feel instant for millions of people at once. The hard parts are hidden: sending the final state instead of the tap so retries and double taps are safe, an optimistic flip that can roll back, rapid like and unlike taps that must end on the right state, counts that are only eventually correct, and the same post showing in many places that must all agree.

The question

Design the like button for a social app with hundreds of millions of users. It sits under every post in the feed, on the post page, and in notifications. Tapping it likes the post, tapping again unlikes it, and the post shows how many people liked it.

Walk me through what happens on a tap, how the request and the count work, what happens when someone taps five times in a second or loses the network, and how you keep every copy of the post in agreement.

Real-life examples: The heart on Instagram posts and Reels, The like button on X (Twitter) posts, The thumbs up under YouTube videos, Reactions on LinkedIn posts, The like button on Facebook posts

Requirements: one tap, many copies, huge numbers

A like button is a heart (or a thumb, or a star) next to a number. Tap it and the heart fills in and the number goes up by one; tap again and both go back. The UI is tiny, so the interview is about what happens between the tap and the server.

This guide stays on the binary toggle and leaves its neighbors to their own guides (linked at the end). Up and down votes are in reddit, claps and batched clicks in medium-blog, and the feeds around the button in twitter, instagram and youtube. The general offline outbox, client-side rate limits and the "Ada liked your post" message are offline-sync-engine, rate-limited-queue and notifications-center.

Questions to ask

Each question below changes a real decision. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Is it a binary like, or reactions, or claps?Binary: liked or not. Reactions are a follow-up.A binary state means the request can say the final state, which makes it idempotent.
Where does the button appear?Feed cards, the post page, notifications, search results, profile grids.Many copies of one post on screen means a shared cache keyed by post id.
Do we show the count? How exact?Yes, compact: 1.2K, 3.4M. Exact only below 1,000.The count can be eventually consistent; nobody can tell 1,203,114 from 1,203,115.
Can the author hide counts?Yes, per post, and viewers can hide counts in their feed.The API must allow the count to be missing; the UI must work without it.
Does the count update live?No. It refreshes when the post is fetched or comes back into view.No socket per post. Live events are a special case.
Mobile web, slow networks, offline?Yes. Many users are on phones with flaky 4G.Optimistic UI, a per-post outbox, and replay when back online.
Double tap on the image to like?Yes, like Instagram.A gesture that only ever likes, never unlikes.

Functional requirements

  • Tap to like, tap again to unlike; the heart and the count change at once.
  • Double tap on the media only likes, and plays a heart burst.
  • The count is short and locale-aware, or absent if hidden.
  • Every copy of the post (feed, post page, a second tab) agrees.
  • A failed save goes back to the server state and says so quietly.
  • Works with a keyboard and a screen reader, which hears whether it is pressed.

Non-functional requirements, with numbers

Suppose 300 million daily users who each like 10 posts a day. That is 3 billion likes a day, about 35,000 per second on average, and perhaps 3 to 5 times that at peak (an estimate; big products publish few exact numbers). A single viral post can get tens of thousands of likes per minute. On the client side, the budgets are about the person, not the servers:

  • Instant feedback: the heart fills in on the next frame after the tap, well under the 100 ms that people perceive as instant, whatever the network does.
  • Correct final state: after any sequence of taps, the server ends up with what the screen shows. Losing a like is a bug; adding two is worse.
  • Accessible: a real button, a target of at least 24 by 24 CSS pixels (44 or 48 is better on touch), and visible focus.

Out of scope

The list of people who liked a post, ranking by likes, and the notification to the author are separate features that read the same like records.

What makes a tiny button hard

Draw the component first so everyone in the room means the same thing. A post card has the media, a row of actions, and the count. The heart is a button, the count sits next to it, and the media above it listens for a double tap.

A feed card wireframe. At the top is the media, which likes the post on a double tap. Below it is the action row: the heart button, pressed, then the count 1.2K, then a share button. A label says the LikeButton is the heart plus the count. Notes on the right say: one tap flips at once and sends the final state; a double tap only likes, never unlikes; the count is compact like 1.2K or 1,2 k, or hidden by the author.Feed card: post by Adamedia: double tap to likeheart1.2KshareLikeButton = heart + countaria-pressed=trueone tap: flip at oncesend the final statedouble tap: like onlynever unlikecount: 1.2K or 1,2 kor hidden by author
The heart is the control; the count sits next to it and may be missing. The media is a second, shortcut way to like, and it only ever likes.

Four properties of the problem drive every later decision:

  1. It is a state, not an event. The person wants the post to end up liked or not liked, not "a tap sent to the server". If the tap is what we send, every retry and double tap changes the answer; if the final state is, they cannot.
  2. It must feel instant, but the network is slow. The heart fills in on the next frame; the reply comes 100 ms to several seconds later, or never. So the screen runs ahead of the server and must be able to step back.
  3. The count is shared by millions and only roughly right. The server cannot lock one number for every tap on a viral post, so the count it returns is a little behind. The client mixes it with the one change it knows for sure: its own.
  4. One post lives in many places. A feed card, the open post page, a notification row and a second tab can all show it. A like in one must show in all, or the app looks broken.

The next five chapters take these four in order; then the pieces come together as an architecture.

Think first

A teammate says: "It's a boolean, just POST /posts/42/like on every tap and flip the heart when the reply comes." What two things go wrong for a person on a train with a weak signal?

Check your understanding · Exercise 1

Which answers from the interviewer would change the client design the most? Select all that apply.

Send the state, not the tap

The first real decision is what the request means. It decides whether retries, double taps and offline replay are safe.

The naive version: a toggle endpoint

The simplest endpoint is POST /posts/42/toggle-like: the server flips whatever it has. Now follow one tap on a bad network. The request reaches the server and the post becomes liked, but the reply is lost in a tunnel.

The client times out and sends the request again. The retry flips the post back to unliked. The screen shows a red heart, the server says not liked, and nobody sees an error: the like is silently gone.

The root cause is that a toggle is not idempotent: an idempotent request has the same effect whether it arrives once or five times. RFC 9110, the HTTP standard, defines PUT, DELETE and safe methods such as GET as idempotent, and POST as not. It says a client should not retry a non-idempotent request automatically, and a proxy must not, so after a lost reply a toggle leaves no safe move.

Two sequence diagrams side by side. Left, with a toggle endpoint: the client sends POST toggle, the server likes the post, the reply is lost, the client retries POST toggle, and the server flips the post back, ending not liked. Right, with a state endpoint: the client sends PUT like, the reply is lost, the client retries PUT like, and the server still ends liked.toggle endpointstate endpointClientServerClientServerPOST togglereply lostretry: POST togglePUT likereply lostretry: PUT likeserver ends: not likedserver ends: liked
The same lost reply and the same retry. A toggle depends on how many requests arrive; PUT only depends on what the last one says.

Decision What should a tap send to the server?

POST a toggle

POST /posts/42/toggle-like

The server reads the current state and flips it

Pros

  • One endpoint

Cons

  • Not idempotent: a retry or a duplicate undoes the like
  • Replaying queued toggles offline gives a random result

POST like and POST unlike

POST /posts/42/likes to like, POST /posts/42/unlikes to unlike

Pros

  • The intent is explicit

Cons

  • Still POST, so generic retry logic must treat it as unsafe
  • A duplicate like adds 2 unless the server dedupes

PUT and DELETE on the like resource

PUT /posts/42/like means "I like this", DELETE means "I don't"

The server keeps one row per (user, post)

Pros

  • Idempotent by definition: send it twice, same result
  • Safe to retry, replay offline and coalesce

Cons

  • Two methods to handle
  • Needs ordering care when requests overlap (see rapid taps)

PUT with a body

PUT /posts/42/like with {"liked": false}

Pros

  • Idempotent, one method
  • Easy to extend to reactions: {"reaction": "love"}

Cons

  • Slightly less obvious than DELETE for an unlike
  • Same ordering care as above

What we pick: Send the final state. This guide uses PUT to like and DELETE to unlike because they read clearly in logs and every HTTP tool knows they are idempotent. A PUT with a body is just as good, and better if reactions are coming. The rule: the request says where the person wants to end up, never which way to move.

What the server stores

The server keeps one record per person per post, with a unique key on (user_id, post_id). A PUT inserts the row, or does nothing if it exists; a DELETE removes it, or does nothing if it is already gone. Because of the key, a thousand duplicate PUTs still leave one row, and the count is built from rows (see the counts chapter), so it cannot double count. Both calls answer with the resulting state.

http
PUT /api/posts/42/like HTTP/1.1
X-Request-Seq: 7

HTTP/1.1 200 OK
Content-Type: application/json

{ "postId": "42", "liked": true, "likeCount": 12841, "seq": 7 }

DELETE /api/posts/42/like HTTP/1.1
X-Request-Seq: 8

HTTP/1.1 200 OK

{ "postId": "42", "liked": false, "likeCount": 12840, "seq": 8 }
Both calls are safe to repeat; the reply carries the truth after the write.

Check your understanding · Exercise 2

A client sends PUT /posts/42/like, the reply is lost, and it retries twice. What is true with the design above? Select all that apply.

The optimistic flip and its state machine

The heart must fill in at once, but the reply arrives somewhere between 100 ms and never. So the button shows what the person asked for before the server agrees. This is an optimistic update: change the screen first, send the request, and undo the change if the request fails. The optimistic-updates lesson covers the general pattern; here is what is specific to a like.

Decision When does the heart change?

After the server replies

Show a spinner or disable the button until the reply

Flip the heart with the server's answer

Pros

  • The screen never lies
  • No rollback code

Cons

  • Feels broken on mobile: 300 ms to 2 s of nothing
  • People tap again

Optimistic, fire and forget

Flip at once, send the request, ignore the reply

Pros

  • Instant
  • Tiny code

Cons

  • Failures are invisible until the next refresh silently removes the like

Optimistic with confirm and rollback

Flip at once and keep the last server state

On success, store the reply; on failure, go back and show a quiet message

Pros

  • Instant and honest
  • Every copy converges on the server's answer

Cons

  • Needs a small state machine
  • A rollback can surprise, so it must be rare and explained

What we pick: Optimistic with confirm and rollback. A like is the textbook case: the request almost always succeeds, it is cheap to undo, and nothing bad happens if the screen leads the server for a second.

Keep two truths: the server's and the person's

The trick that makes rollback simple is to never overwrite the server's answer with the guess. For each post the client keeps the last confirmed serverLiked and serverCount, plus a separate pendingLiked that is set only while a change is on its way.

The screen is computed from them. The heart is pendingLiked if set, otherwise serverLiked, and the count is the server count plus one if the person is adding a like, minus one if removing. A rollback is then just "clear pendingLiked": the screen falls back to the last thing the server said, with no arithmetic to undo.

ts
type LikeState = {
  serverLiked: boolean;
  serverCount: number | null; // null when counts are hidden
  pendingLiked: boolean | null; // what the person asked for, not yet confirmed
  status: "idle" | "pending" | "failed";
};

export function view(s: LikeState) {
  const liked = s.pendingLiked ?? s.serverLiked;
  const delta = liked === s.serverLiked ? 0 : liked ? 1 : -1;
  const count = s.serverCount === null ? null : Math.max(0, s.serverCount + delta);
  return { liked, count };
}
The displayed state is derived, so a rollback is one assignment.

The state machine

Four states are enough. Idle: the screen matches the server. Pending: the person asked for a change and a request is out, or waiting to go out (see rapid taps).

Confirmed is a moment, not a place to stay: the reply is stored and the button is idle again, or pending again if the person tapped while the request was out. Failed: the request failed for good, so the button rolls back to the server state, shows a short message, and returns to idle. Writing it as a machine rules out impossible mixes, like a rollback arriving after a newer tap already succeeded.

The like button state machine. Idle, where the screen equals the server, goes to pending on a tap. Pending, where pendingLiked is set, loops on itself on another tap or a retry. A 2xx reply goes to confirmed, which stores the reply and settles back to idle, or goes back to pending if the person tapped while the request was out. A final error goes to failed, which clears the pending value, rolls back with a toast, and returns to idle.settledidlescreen = serverpendingpendingLiked setconfirmedstore the replyfailedpending clearedtap2xxtapped meanwhilefinal errortap again, retryroll back, quiet toast
Every arrow back to idle leaves the screen equal to the server. A rollback only clears pendingLiked; nothing is subtracted by hand.

The happy path, from the tap to the re-render: the heart changes before the network call starts.

Sequence of a successful like between the Button, the Store, the Coalescer, and the Server. The tap calls setPending true on the store, which re-renders the button red with 121 at once. The store hands the change to the coalescer, which waits 400 ms, then sends PUT like for post 42. The server replies 200, liked, 121. The coalescer confirms the reply into the store, and the view stays the same, with no flicker.ButtonStoreCoalescerServertap: setPendingre-render: red, 121settle 400 msPUT like200 liked, 121confirm(reply)no flicker
The re-render happens in the second step, before any network work. The reply changes the stored server values, but the derived view is already right, so nothing moves.

When it fails

Not every failure is final. A network error or a 503 gets a couple of retries with backoff (the retries-and-backoff lesson), which is safe because PUT and DELETE are idempotent. A 401 sends the person to sign in, and a 404 means the post was deleted, so the card can say so.

Only after the retries are used up does the button roll back. The message is a small toast ("Couldn't like this post. Try again."), never a modal: the person has already scrolled on.

Sequence of a failed like. The tap calls setPending true on the store. The store hands it to the coalescer, which settles and sends PUT like. The server replies 503. The coalescer retries after 1 second, and the server replies 503 again. The coalescer calls fail on the store, which clears the pending value, and the button re-renders with an empty heart, 120, and a quiet toast.ButtonStoreCoalescerServertap: setPendingsettle, then sendPUT like503retry after 1 s503 againfail: clear pendingempty, 120, toast
Retrying is safe because PUT is idempotent. Only after the retries run out does the store clear pendingLiked, and the view falls back to 120 by itself.

Rate limits and abuse

Likes are cheap, so bots use them to inflate counts and to spam authors with notifications. The defenses live on the server: per-account and per-IP rate limits, bot detection, discounting suspicious likes, and notifying an author only about a person's first like on a post. Never trust the client to enforce any of this.

  • On 429 Too Many Requests, do not retry at once or roll back silently. Keep the choice pending, wait for Retry-After, and say "You're liking too fast. Try again in a minute." If the limit still holds, roll back like any other failure.
  • Authenticate the request with the session and protect it like any state-changing call (SameSite cookies or a CSRF token): a like is an action taken in the person's name.

Check your understanding · Exercise 3

The server count is 120 and serverLiked is false. The person taps like, then the request fails for good. What should the screen show after the rollback? Select all that apply.

Rapid taps: only the final state matters

People tap fast: twice by accident, like and unlike while deciding, or just for the animation. With state requests, two problems remain: five requests where one would do, and requests and replies that arrive out of order.

Why order matters

Tap like, then unlike, 200 ms apart. The PUT goes out first and the DELETE second, but they can travel on different connections or reach different servers. If the DELETE lands first and the PUT second, the server ends up liked while the screen shows unliked.

Even when the server order is right, the replies can come back in the wrong order: a late PUT reply saying liked: true arrives after the DELETE reply and overwrites the newer truth. The request-races lesson covers this family of bugs.

Decision How should the client handle a burst of taps on one post?

Send every tap

Each tap sends PUT or DELETE at once

Pros

  • Simplest code

Cons

  • 5 taps, 5 requests
  • Requests and replies can cross, ending in the wrong state

Disable the button while pending

Ignore taps until the reply arrives

Pros

  • At most one request in flight
  • No ordering problem

Cons

  • On a slow network the button feels dead for seconds

Debounce, then send the final state

Every tap updates pendingLiked and restarts a short timer (about 300 to 500 ms, an estimate to tune)

When it fires, send the final state, or nothing if it equals the server state

Pros

  • Like, unlike, like sends one PUT; like, unlike sends nothing

Cons

  • A request can still be in flight when the next burst starts
  • The tab may close before the timer fires

Coalesce: one in flight per post, latest wins

Debounce as above, with one request per post at a time; taps during it only change pendingLiked

When the reply arrives, send once more if pendingLiked still differs

Tag requests with a sequence number; apply only the newest one's reply

Pros

  • At most 2 requests for one burst
  • Requests and replies stay in order, so the server ends on the last tap

Cons

  • The most code of the four (still about 40 lines)

What we pick: Coalesce. The UI is already optimistic, so waiting a few hundred milliseconds to send costs the person nothing. One request in flight per post keeps requests and replies in order, and the sequence number is a cheap guard that drops a reply to anything but the newest request, such as one the client gave up on after a timeout. The debounce-throttle and request-deduplication lessons cover the general tools.

A timeline of taps and requests. The person taps like at 0 ms, unlike at 200, like at 400, and unlike at 900. Sending every tap fires four requests, PUT, DELETE, PUT, DELETE, that overlap in flight. Debouncing by 400 ms sends PUT at 800 ms, which is in flight until 1500, and DELETE at 1300 while the PUT is still in flight. Coalescing sends PUT at 800 and waits for its reply, then sends DELETE at 1500, so the two never overlap.tapsLULUevery tapPUTDELPUTDELdebouncePUTDELcoalescePUTDELPUT in flight0 ms800 ms1.5 s
Debounce alone cuts the count but can still overlap a request with the next burst. Coalescing adds one rule, one request in flight per post, and the order on the server can no longer be wrong.
ts
const SETTLE_MS = 400; // estimate: long enough to absorb a burst

export function requestLike(id: string, liked: boolean, source: LikeSource) {
  store.setPending(id, liked, source); // the screen updates now
  const job = jobs.get(id) ?? { timer: 0, inFlight: false, seq: 0 };
  clearTimeout(job.timer);
  job.timer = window.setTimeout(() => {
    job.timer = 0;
    flush(id);
  }, SETTLE_MS);
  jobs.set(id, job);
}

async function flush(id: string) {
  const job = jobs.get(id)!;
  const s = store.get(id);
  // wait for the reply, or for the burst to settle
  if (job.inFlight || job.timer || s.pendingLiked === null) return;
  if (s.pendingLiked === s.serverLiked) return store.clearPending(id);
  job.inFlight = true;
  const seq = ++job.seq;
  try {
    const reply = await api.setLike(id, s.pendingLiked, seq); // retries inside
    if (seq === job.seq) store.confirm(id, reply); // only the newest request
  } catch (err) {
    store.fail(id, err); // back to the server values
  } finally {
    job.inFlight = false;
    flush(id); // the person may have tapped again meanwhile
  }
}
Per-post coalescing: the timer and the in-flight flag live outside React, keyed by post id.

If the tab is hidden or closed during the settle delay, flush at once on visibilitychange (state hidden) or pagehide, using fetch with keepalive: true. store.confirm clears pendingLiked only if it equals the reply, so a tap made during the request is sent by the next flush. store.fail always clears it, which is right: with two values, the latest wish either matches the server already or is the change that just failed.

Try it below: pick a network, tap quickly, and compare how many requests go out and where the server and the screen end up.

Strategy
Network

Tap the heart a few times. Each tap is 150 ms after the last.

Tap lab with POST toggle on a slow network. Tap the heart a few times. Each tap is 150 ms after the last.no requests yetyou wanted: not likedscreen: not liked, 120server: not liked, 120all three agree
Tap the heart a few times. Each tap is 150 ms after the last. Green arrived in order, gray arrived out of order, red lost its reply, hatched is a retry. On slow and flaky networks, requests alternate between 900 ms and 300 ms each way, so they can overtake each other.

Double tap to like

Instagram made double tapping the photo a second way to like. The rule that keeps it safe: a double tap only ever likes. If the post is already liked, the double tap plays the heart burst again and sends nothing. Otherwise a person who double taps a photo they already liked would unlike it, which nobody intends.

  • Detect it with pointer events: two pointerup events within about 300 ms and 10 px of each other (estimates). On desktop, dblclick does this already.
  • Set touch-action: manipulation on the media so a double tap is not taken as a zoom.
  • If a single tap also does something (pause a video), delay it until the double tap window has passed.

Check your understanding · Exercise 4

The post starts unliked. Within 300 ms the person taps like, unlike, like, unlike. With the coalescing design, which statements are true? Select all that apply.

Counts at scale: roughly right, never going backwards

The heart is the person's own state, and the client knows it exactly. The count is everyone's state, and nobody knows it exactly. The goal is that the one part the person can check, their own like, always adds up.

Why the server count lags

The naive server does UPDATE posts SET like_count = like_count + 1 inside every like. That works until a celebrity posts. Ten thousand likes a minute on one row means every write waits for a lock on it. Large apps publish few details, but the common patterns are well known:

  • The like row is the source of truth. Each person writes their own (user, post) row, so there is no contention.
  • The count is derived, asynchronously. A worker applies like and unlike events in batches: one +37 a second beats 37 separate +1s.
  • Sharded counters. A hot count is split into, say, 16 shards updated independently and summed on read. Google's Firestore docs describe this pattern for counters with many writes.
  • Cached reads. Facebook's TAO paper (USENIX ATC 2013) models likes as edges (associations) in a social graph. TAO can keep a count per edge list as edges are added and removed, and serve it from its cache tier in constant time, instead of counting edges on every read.
How the like count is built on the server. The client sends PUT like to the Like API, which writes one like row per user and post. Change events flow from the rows into an event stream, then to a counter worker that applies batches such as plus 37 once a second to a sharded counter with 16 shards that are summed. The sum goes into a count cache, which the Feed API reads, and the client receives likeCount a few seconds old.ClientLike APIlike rowsone per user+postevent streamcounter workerbatch: +37sharded count16 shards, summedcount cacheFeed APIPUTchange eventsevery secondsumlikeCount,seconds old
The write path never touches the shared count, so a viral post does not queue on one hot row. The price is a count a few seconds behind, which the client hides for the person's own like.

So the count the client receives can be a few seconds behind. Nobody notices 1,203,114 versus 1,203,115, unless the missing like is their own.

Do not let the person's own like disappear

The person taps like on a post with 120 likes, and the screen shows 121 at once. The PUT succeeds, and the reply says likeCount: 120, because the counter worker has not caught up. If the client trusts that number, the heart is red and the count drops back to 120. To the person it looks like the like did not count.

Decision How do we keep the person's own like in the count?

Trust the server number

Show whatever the latest reply says

Pros

  • No extra logic

Cons

  • The count drops right after a like, which looks like a bug

Server reads its own write

Replies and reads adjust the cached count for the viewer's own row if the counter has not applied it yet

Pros

  • Correct on every device and surface
  • One place for the rule

Cons

  • The server must know whether the counter has applied this like, for example from a timestamp on the row and a watermark on the counter

Client guard

After a confirmed like, remember the count the person saw (121) for a short window, such as 60 s (an estimate)

Until it expires, never show less for this post unless the person unliked

Pros

  • Pure client code
  • Also hides small dips while the counter catches up

Cons

  • Only protects the device that made the change
  • Must expire, or it hides real drops

What we pick: Do both. Ask the server to read its own writes for the caller: it is the only fix that works across devices. Add the small client guard anyway, because feed responses come from caches that can be older than the like reply.

Showing the number: compact and locale aware

Products show 1.2M, not 1,203,114. The browser does this for every language with Intl.NumberFormat and notation: "compact", and the locale-formatting lesson covers Intl in depth.

Two details matter. Round down with roundingMode: "trunc" (Chrome 106, Firefox 116 and Safari 15.4 onward), so 1,999 shows 1.9K, not 2K, a number the post has not reached. And keep exact numbers for small counts, where each like is visible; English compact notation already does this below 1,000.

ts
const formatters = new Map<string, Intl.NumberFormat>();

export function formatCount(n: number, locale: string): string {
  let f = formatters.get(locale);
  if (!f) {
    f = new Intl.NumberFormat(locale, {
      notation: "compact",
      maximumFractionDigits: 1,
      roundingMode: "trunc",
    });
    formatters.set(locale, f);
  }
  return f.format(n);
}
Create the formatter once per locale; constructing one per render is slow.
Compact notation in Node 22 with maximumFractionDigits 1 and roundingMode trunc. Browsers ship their own locale data, so small details can differ.
Locale1,23412,3451,234,567
en-US1.2K12.3K1.2M
de-DE123412.3451,2 Mio.
ja-JP12341.2万123.4万
en-IN1.2K12.3K12.3L
hi-IN1.2 हज़ार12.3 हज़ार12.3 लाख
fr-FR1,2 k12,3 k1,2 M

German does not abbreviate thousands, Japanese groups by ten thousand (万), and Indian English and Hindi count in lakh (one hundred thousand). A hand-written K and M helper gets all of them wrong. The compact text also changes width (999 to 1K), so give the count a minimum width, with font-variant-numeric: tabular-nums for equal-width digits, so the row does not jump.

Whether to show the count at all

Instagram started testing hidden like counts in Canada in 2019 and widened the test to more countries that year. On May 26, 2021, it gave everyone on Instagram and Facebook the option to hide like counts on all posts in their feed, and on their own posts. So the design must work without a number.

The API sends likeCount: null with a reason, the button still toggles, and the slot either disappears or shows a phrase like "Liked by Ada and others". The author can still see the number on their own post. Do not just hide it with CSS: a number in the response is visible in the network panel.

Check your understanding · Exercise 5

Which statements about the like count are right? Select all that apply.

One post, many copies on screen

Open a post from the feed: the post page shows a big heart, while the feed card stays mounted behind it and the notifications panel has a row about it. Like it on the post page and close the page. If the feed card still shows an empty heart and the old count, the person concludes the like was lost, and the app looks broken even though the server is right.

Decision Where does the like state live so every copy agrees?

State inside each button

Each LikeButton keeps useState seeded from its props

Pros

  • Simple, self-contained

Cons

  • Copies drift apart the moment one changes
  • Navigating back shows stale data

Events between copies

The button emits post-liked on a global event bus; other copies listen

Pros

  • Works without a shared store

Cons

  • A copy mounted after the event never hears it

A normalized cache keyed by post id

Every response is split into entities, so post 42 is stored once

Every button reads posts[42]; the optimistic write, confirm and rollback change that one record

Pros

  • One write, every copy re-renders
  • Copies mounted later read the current value

Cons

  • Needs a cache layer and a rule for merging responses

What we pick: A normalized cache keyed by post id. This is what the normalization lesson describes, and server-state libraries support it: Apollo Client and Relay normalize by id automatically, and with TanStack Query you update every cached query that contains the post. The button becomes a view of one record.

One merge rule matters. A feed page requested before the like may arrive after it, still saying post 42 is not liked. So merging a response updates serverLiked and serverCount only, and leaves pendingLiked alone. Because the view is derived, a pending like survives any refetch.

Other tabs

A second tab has its own memory and cache. After a confirmed change, post a small message such as { postId: "42", liked: true, likeCount: 12841 } on a BroadcastChannel, and other tabs merge it as server data. Send it after the confirm, not on the tap, or a rollback in one tab leaves the others wrong. The cross-tab-sync lesson covers the channel and its fallbacks.

Live counts: usually not

For a normal feed, the count does not tick up live: a subscription per visible post costs a lot, and people rarely watch a count. Refresh the post when it is fetched again, when the tab becomes visible, or when the card scrolls back into view after a while.

A live event, such as a broadcast with thousands of viewers, is the exception. Push count updates over the existing socket, throttled on the server to about one update per post every few seconds (an estimate), and animate the number rather than redrawing it on every message.

Try it: like the post from the feed card, the post page or the second tab, with and without a shared cache, and see which copies follow. The same lab shows the count in compact form for a few locales.

State

Like the post from any copy.

Sync lab, state in each button, locale en-US. Feed card: not liked, 12.3K. Post page: not liked, 12.3K. Tab 2: not liked, 12.3K. Server: not liked.Feed cardlike12.3Kin syncPost pagelike12.3Kin syncTab 2like12.3Kin syncserver: not likedexact: 12,345each copy keeps its own state
Like the post from any copy. With state in each button only the tapped copy changes. A normalized cache updates every copy in the same tab; the second tab needs a broadcast.

Check your understanding · Exercise 6

With a normalized cache, which of these keep every copy of post 42 correct? Select all that apply.

Architecture: a thin button over a shared like service

The key boundary: the button knows nothing about the network. It reads one record and calls one function. Everything shared across copies, tabs and time (the record, the coalescer, the outbox) lives below it, in plain modules any surface can use.

The like button's component architecture. In the UI zone, the LikeButton with aria-pressed and the DoubleTapLayer are used in the feed, the post page and notifications. Taps go to the Post cache in the shared like core, which gives the view back to the button. The Post cache schedules the Coalescer, which uses the Outbox in IndexedDB when offline and sends through the API client to the Like API with PUT or DELETE. The Post cache also feeds Analytics and the Tab channel.UI, every surfaceLikeButtonaria-pressedDoubleTapLayerfeed, post page,notificationsLike core, sharedPost cachenormalized by idCoalescertimer, seqOutboxIndexedDBPlatformAnalyticson confirmTab channelBroadcastChannelAPI clientretries, 429Like APItapviewPUT, DELETE
The button is a view of one cache record. Everything that must outlive a component (the record, the timer, the outbox) lives in the shared core, so a burst survives the card unmounting.
PartResponsibilityWhy the boundary is here
LikeButtonDraws the heart, the count and the burst; calls requestLike; reads the derived view.Rendered in every post, so it stays tiny and stateless.
DoubleTapLayerWraps the media, detects the double tap, and calls requestLike with liked set to true.Only some surfaces have media; the gesture stays out of the button.
Post cacheOne normalized record per post: server fields plus pendingLiked and status.Every copy reads it, so one write updates all of them.
Like coalescerPer-post settle timer, one request in flight, sequence numbers.Ordering is per post and must survive components unmounting.
OutboxKeeps unsent final states in IndexedDB while offline.Must outlive a reload; see Offline below.
API clientPUT and DELETE, retries with backoff, 429 handling.One place for HTTP rules.
Tab channelSends and receives confirmed changes on BroadcastChannel.Other tabs merge them as server data.
AnalyticsRecords one event per confirmed change, with the surface.Counts business events, not taps (see Analytics).

Why is the coalescer not a hook inside the button? Because the button can unmount mid-burst: the person likes a post in the feed and immediately opens it. A hook would lose its timer with the component. A module-level map keyed by post id does not care which component asked.

How the data moves

Reads and writes take different paths. Reads come with the feed or the post, since the like state is two fields on the post. Writes are the small PUT and DELETE calls, whose reply goes into the cache and from there to every copy and every tab.

The like data flow. On the server, the Feed API sends feed JSON with viewerHasLiked and likeCount to the API client in the browser, which merges it into the Post cache. The Post cache gives the view to three LikeButton copies. A tap goes back into the Post cache, which passes the pending final state to the Coalescer. The Coalescer sends PUT or DELETE through the API client to the Like API, which replies with liked and the count. The Post cache broadcasts confirmed changes to other tabs.ServerFeed APILike APIBrowser tabAPI clientretry, seqCoalescerPost cacheposts[id]LikeButton x3other tabsfeed JSONPUT, DELETEliked, countviewtappendingbroadcast
Reads need no extra request: the like fields ride on the post. Writes are one small call per post, and the reply enters the cache like any other server data, then reaches every copy and every tab.

The data model

The client stores very little per post: two server fields, one pending field, and a status. The server fields are viewerHasLiked and likeCount, the API's names for serverLiked and serverCount in the optimistic chapter. Most of the design is about where each piece lives, because that decides what survives a re-render, a navigation, a reload and a second tab.

ts
type PostId = string;

type Post = {
  id: PostId;
  authorName: string;
  // ...media, caption, timestamps
  viewerHasLiked: boolean; // server truth for this viewer
  likeCount: number | null; // null when the count is hidden
  likeCountHidden?: "by-author" | "by-viewer";
};

type LikeMeta = {
  pendingLiked: boolean | null;
  status: "idle" | "pending" | "failed";
  guardCount?: { min: number; until: number }; // the own-like guard from the counts chapter
};

type OutboxEntry = { postId: PostId; liked: boolean; queuedAt: number };
The like fields ride on the post record in the normalized cache.
DataWhere it livesWhy
Post with viewerHasLiked, likeCountNormalized cache in memoryShared by every copy; refreshed by any response that contains the post.
LikeMetaNext to the post in the same cacheSurvives the button unmounting; one per post, not per copy.
Coalescer jobs (timer, in-flight flag, seq)Module-level Map keyed by post idNot data to render, so it stays out of the store.
OutboxEntryIndexedDB, one entry per postMust survive a reload while offline; keyed by post so it holds only the final state.
Burst animation playingComponent statePurely visual and local to one copy.
Likes shown in the URLNowhereA like is not navigation state.

The API

CallPurposeReply
PUT /api/posts/{id}/likeMake the viewer's state liked. Idempotent.200 with { postId, liked: true, likeCount, seq }
DELETE /api/posts/{id}/likeMake the viewer's state not liked. Idempotent.200 with { postId, liked: false, likeCount, seq }
GET /api/feed, GET /api/posts/{id}Reads; each post carries viewerHasLiked and likeCount.Posts, possibly from a cache a few seconds old
POST /api/likes:batchOptional: replay an offline outbox in one request.Per-post results, each like the single replies

Errors use one JSON shape so the client can decide without parsing text: a stable code, a human message, and for 429 a Retry-After header in seconds. The seq header the client sends is echoed back, so the coalescer can match the reply to the request.

json
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "You're doing that too often. Try again in a minute.",
    "retryAfterSeconds": 60
  }
}
A 429 reply. The client waits at least Retry-After before trying again.

The component interface

tsx
type LikeButtonProps = {
  postId: string;
  /** Used in the accessible name: "Like post by Ada". */
  authorName: string;
  /** Where it is shown, for analytics: "feed", "post", "notification". */
  surface: "feed" | "post" | "notification" | "search" | "profile";
  size?: "sm" | "md";
  showCount?: boolean;
};

// <LikeButton postId={post.id} authorName={post.authorName} surface="feed" />
The button takes an id, not a liked flag: it reads the rest from the cache.

Passing liked and count as props is the tempting alternative, and it is what produces drifting copies: the parent passes a snapshot and the button cannot know when another copy changed it. Passing the id makes the button a reader of the shared record.

Offline: one outbox entry per post

Because every request is a final state per post, offline is simple. When a send fails because the device is offline, the coalescer moves the final state into the outbox, a small queue of unsent changes, instead of rolling back. The outbox keeps one entry per post at most, so like then unlike while offline overwrites the entry, then removes it once it equals the server state.

When the online event fires, or the next request succeeds, the outbox replays, in one batch call if there are many. The general version, with conflicts and sync order, is the offline-sync-engine guide. A like needs none of that, because the last state wins and replaying it twice is harmless.

While offline the heart stays filled, but the status stays pending; a small clock icon or a note ("Will sync when you're back online") is honest without being noisy. If an entry is older than a day or two (an estimate), drop it and roll back: a like from last week would surprise the author.

Check your understanding · Exercise 7

While offline, the person likes post 42, unlikes it, and likes post 77. What should the outbox hold when the network returns? Pick one.

Animation, accessibility, and analytics

The heart burst is half of why people like tapping. Make it cheap: animate only transform and opacity, which the browser can run on the compositor without layout or paint (the reflow-repaint-composite lesson explains why). Scale the heart from 1 to about 1.2 and back over 200 to 300 ms, and fade a few particles out.

The animation must never block the next tap: a tap during the burst restarts it or switches state at once. Under prefers-reduced-motion: reduce, swap the burst for a plain color change. A short navigator.vibrate(10) adds a tick on Chrome for Android, but Safari and every other iOS browser lack the Vibration API and Firefox for Android ignores it, so feature-detect it and treat it as a bonus.

tsx
export function LikeButton({ postId, authorName, surface }: LikeButtonProps) {
  const { liked, count } = useLikeView(postId);
  const locale = useLocale();
  return (
    <span className="like">
      <button
        type="button"
        className="like-heart"
        aria-pressed={liked}
        aria-label={`Like post by ${authorName}`}
        onClick={() => requestLike(postId, !liked, { surface, method: "button" })}
      >
        <HeartIcon aria-hidden="true" />
      </button>
      {count !== null && <span className="like-count">{formatCount(count, locale)}</span>}
    </span>
  );
}
A real toggle button: the name stays the same, aria-pressed carries the state.
css
.like-heart { min-width: 44px; min-height: 44px; touch-action: manipulation; }
.like-heart:focus-visible { outline: 2px solid var(--cf-color-focus-ring); }
.like-heart[aria-pressed="true"] { color: var(--cf-color-danger); }
.like-heart.is-bursting svg { animation: pop 240ms ease-out; }
@keyframes pop { 50% { transform: scale(1.2); } }
@media (prefers-reduced-motion: reduce) {
  .like-heart.is-bursting svg { animation: none; }
}
Only transform animates; reduced motion keeps the color change. The burst class is set on a tap, not on render, so liked posts do not pop as they scroll into view.

Accessibility details that are specific to this button, following the WAI-ARIA button pattern (the aria-patterns lesson has the full picture):

  • aria-pressed, not a changing label. Screen readers announce a toggle button and whether it is pressed, in words that vary by reader. The APG warns that the label must not change with the state: "Unlike, pressed" contradicts itself.
  • A name that says which post. A feed has 30 buttons called "Like"; "Like post by Ada" tells them apart in a list of controls.
  • Do not announce every count change. A count in an aria-live region would interrupt on every refresh. The pressed state is the feedback.
  • Target size. WCAG 2.2 success criterion 2.5.8 (level AA) asks for at least 24 by 24 CSS pixels, or enough spacing around a smaller target. 2.5.5 (level AAA) asks for 44 by 44, Apple's guidelines for 44 by 44 points and Material Design for 48 by 48 dp. The icon can stay 20 px; pad the button.
  • Failure feedback is announced. The rollback toast uses a polite live region, so a screen reader user learns that the heart went back.

Analytics: count likes, not taps

Product teams want to know how many likes come from the feed, the post page or a double tap. Record the event when the server confirms the final state, not on each tap: a like, unlike, like burst is one like. Send the surface and the method (button or double-tap) with it. The server already counts the likes, so this event is only for attribution, never the like count.

Check your understanding · Exercise 8

Which choices make the like button accessible? Select all that apply.

Running it in the interview

This is usually a 30 minute question, and the trap is spending 20 of them on the heart icon. A plan that works:

  1. Minutes 0 to 4: scope. Binary or reactions? Where does it appear? Counts shown, exact or compact, hideable? Live or not?
  2. Minutes 4 to 10: the API. Say "send the state, not the tap", write PUT and DELETE with one row per (user, post), and explain the lost-reply retry that breaks a toggle.
  3. Minutes 10 to 18: the client. The state machine, the two truths, rollback, and coalescing with one request in flight.
  4. Minutes 18 to 24: counts and copies. Eventual counts, the own-like guard, compact formatting, the normalized cache, and tabs.
  5. Minutes 24 to 30: the rest. The offline outbox, 429, aria-pressed, the burst, and analytics on confirm.

Follow-up questions to expect, and the shape of a strong answer:

  • "Now make it reactions, like LinkedIn." Still one row per (user, post), now holding a reaction type. The request becomes PUT with { reaction: "celebrate" }, and DELETE removes it. Counts become a small map per type. Everything else stays.
  • "Why not WebSockets for live counts?" Cost per viewer, and nobody watches counts. Refresh on view; push throttled updates only for live events.
  • "The count went down after I liked. Why?" The counter lags the rows. Fix it with server read-your-writes for the caller plus a short client guard.

Check your understanding · Exercise 9

The interviewer asks you to support reactions (like, love, celebrate). Which parts of this design change? Select all that apply.

Summary

  • Send the final state (PUT to like, DELETE to unlike), never a toggle: idempotent requests make retries, double taps and offline replay safe.
  • Flip the heart at once, keep the server's values separate from the pending choice, and derive the view; a rollback is then one assignment.
  • Coalesce rapid taps per post: a short settle delay, one request in flight, and sequence numbers so stale replies are ignored.
  • The count is eventually consistent on the server (async, batched, sharded). Show the server count plus the person's own change, and never let their own like disappear.
  • Format counts with Intl.NumberFormat compact notation, rounded down, and support hidden counts by not sending them.
  • Keep like state in a normalized cache keyed by post id, and broadcast confirmed changes to other tabs, so every copy agrees.
  • Make it a real toggle button with aria-pressed, a name that identifies the post, a large target, and a burst built from transform and opacity.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next