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.
| Question | Answer we assume | What 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.
Four properties of the problem drive every later decision:
- 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.
- 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.
- 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.
- 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?
First, the heart waits for the network, so the person taps again, and now two requests are in flight.
Second, if the endpoint toggles, the two requests cancel out, and a retry after a timeout flips it again. The next two chapters fix both: send the final state, and flip the heart before the reply.
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.
PUT only depends on what the last one says.Decision What should a tap send to the server?
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.
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 }
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?
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.
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 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.
pendingLiked; nothing is subtracted by hand.The happy path, from the tap to the re-render: the heart changes before the network call starts.
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.
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 forRetry-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 (
SameSitecookies 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?
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.
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
}
}
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.
Tap the heart a few times. Each tap is 150 ms after the last.
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
pointerupevents within about 300 ms and 10 px of each other (estimates). On desktop,dblclickdoes this already. - Set
touch-action: manipulationon 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
+37a 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.
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?
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.
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);
}
| Locale | 1,234 | 12,345 | 1,234,567 |
|---|---|---|---|
en-US | 1.2K | 12.3K | 1.2M |
de-DE | 1234 | 12.345 | 1,2 Mio. |
ja-JP | 1234 | 1.2万 | 123.4万 |
en-IN | 1.2K | 12.3K | 12.3L |
hi-IN | 1.2 हज़ार | 12.3 हज़ार | 12.3 लाख |
fr-FR | 1,2 k | 12,3 k | 1,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?
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.
Like the post from any copy.
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.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
LikeButton | Draws the heart, the count and the burst; calls requestLike; reads the derived view. | Rendered in every post, so it stays tiny and stateless. |
DoubleTapLayer | Wraps 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 cache | One normalized record per post: server fields plus pendingLiked and status. | Every copy reads it, so one write updates all of them. |
| Like coalescer | Per-post settle timer, one request in flight, sequence numbers. | Ordering is per post and must survive components unmounting. |
| Outbox | Keeps unsent final states in IndexedDB while offline. | Must outlive a reload; see Offline below. |
| API client | PUT and DELETE, retries with backoff, 429 handling. | One place for HTTP rules. |
| Tab channel | Sends and receives confirmed changes on BroadcastChannel. | Other tabs merge them as server data. |
| Analytics | Records 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 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.
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 };
| Data | Where it lives | Why |
|---|---|---|
Post with viewerHasLiked, likeCount | Normalized cache in memory | Shared by every copy; refreshed by any response that contains the post. |
LikeMeta | Next to the post in the same cache | Survives the button unmounting; one per post, not per copy. |
| Coalescer jobs (timer, in-flight flag, seq) | Module-level Map keyed by post id | Not data to render, so it stays out of the store. |
OutboxEntry | IndexedDB, one entry per post | Must survive a reload while offline; keyed by post so it holds only the final state. |
| Burst animation playing | Component state | Purely visual and local to one copy. |
| Likes shown in the URL | Nowhere | A like is not navigation state. |
The API
| Call | Purpose | Reply |
|---|---|---|
PUT /api/posts/{id}/like | Make the viewer's state liked. Idempotent. | 200 with { postId, liked: true, likeCount, seq } |
DELETE /api/posts/{id}/like | Make 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:batch | Optional: 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.
{
"error": {
"code": "RATE_LIMITED",
"message": "You're doing that too often. Try again in a minute.",
"retryAfterSeconds": 60
}
}
The component interface
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" />
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.
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>
);
}
.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; }
}
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-liveregion 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:
- Minutes 0 to 4: scope. Binary or reactions? Where does it appear? Counts shown, exact or compact, hideable? Live or not?
- Minutes 4 to 10: the API. Say "send the state, not the tap", write
PUTandDELETEwith one row per (user, post), and explain the lost-reply retry that breaks a toggle. - Minutes 10 to 18: the client. The state machine, the two truths, rollback, and coalescing with one request in flight.
- Minutes 18 to 24: counts and copies. Eventual counts, the own-like guard, compact formatting, the normalized cache, and tabs.
- 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
PUTwith{ reaction: "celebrate" }, andDELETEremoves 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 (
PUTto like,DELETEto 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.NumberFormatcompact 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 fromtransformandopacity.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- RFC 9110: HTTP Semantics, idempotent methods Why PUT and DELETE may be repeated and POST may not.
- WAI-ARIA Authoring Practices: Button pattern Toggle buttons with aria-pressed, and why the label should not change.
- MDN: aria-pressed
- MDN: Intl.NumberFormat() constructor The notation, compactDisplay and roundingMode options.
- Understanding WCAG 2.2: Target Size (Minimum)
- MDN: prefers-reduced-motion
- Instagram: Giving People More Control on Instagram and Facebook (May 2021) The option to hide like counts, after testing it since 2019.
- TAO: Facebook's Distributed Data Store for the Social Graph (USENIX ATC 2013) Likes as graph edges, with counts served from cache.
- Engineering at Meta: TAO, the power of the graph (2013) The same design in a shorter form, including constant-time count queries.
- Firebase: Distributed counters Sharded counters for values with many writes.
- MDN: Navigator.vibrate() Limited support; check the compatibility table before relying on it.
Concepts behind it
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.
- REST for frontendsA way to shape an HTTP API around resources and a few standard verbs, so the frontend can fetch, cache, retry, and handle errors in the same predictable way everywhere.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.