Like System — Low-Level Design
A like system records user reactions to content and displays counts. At scale, naive approaches (COUNT(*) per item) collapse under traffic. This design is asked at Meta, Twitter, and any social platform — it touches denormalization, cache invalidation, and write amplification at high volume.
Core Data Model
Like
user_id BIGINT NOT NULL
content_id BIGINT NOT NULL
content_type TEXT NOT NULL -- 'post', 'comment', 'photo'
created_at TIMESTAMPTZ
PRIMARY KEY (user_id, content_id, content_type)
ContentStats
content_id BIGINT NOT NULL
content_type TEXT NOT NULL
like_count BIGINT DEFAULT 0
PRIMARY KEY (content_id, content_type)
-- Index for "all posts liked by user X" (profile page, activity feed)
CREATE INDEX idx_likes_user ON Like(user_id, created_at DESC);
Toggle Like (Add/Remove)
def toggle_like(user_id, content_id, content_type):
try:
db.execute("""
INSERT INTO Like (user_id, content_id, content_type, created_at)
VALUES (%(uid)s, %(cid)s, %(ctype)s, NOW())
""", {'uid': user_id, 'cid': content_id, 'ctype': content_type})
# Increment count
db.execute("""
INSERT INTO ContentStats (content_id, content_type, like_count)
VALUES (%(cid)s, %(ctype)s, 1)
ON CONFLICT (content_id, content_type)
DO UPDATE SET like_count = ContentStats.like_count + 1
""", {'cid': content_id, 'ctype': content_type})
# Invalidate cache
cache.delete(f'like_count:{content_type}:{content_id}')
cache.delete(f'user_liked:{user_id}:{content_type}:{content_id}')
return {'liked': True, 'delta': +1}
except UniqueViolation:
# Already liked — remove it
db.execute("""
DELETE FROM Like
WHERE user_id=%(uid)s AND content_id=%(cid)s AND content_type=%(ctype)s
""", {'uid': user_id, 'cid': content_id, 'ctype': content_type})
db.execute("""
UPDATE ContentStats SET like_count = GREATEST(0, like_count - 1)
WHERE content_id=%(cid)s AND content_type=%(ctype)s
""", {'cid': content_id, 'ctype': content_type})
cache.delete(f'like_count:{content_type}:{content_id}')
cache.delete(f'user_liked:{user_id}:{content_type}:{content_id}')
return {'liked': False, 'delta': -1}
Reading Like Count and User Like Status
def get_content_stats(content_id, content_type, viewer_user_id=None):
# Like count from cache or DB
cache_key = f'like_count:{content_type}:{content_id}'
count = cache.get(cache_key)
if count is None:
row = db.execute("""
SELECT like_count FROM ContentStats
WHERE content_id=%(cid)s AND content_type=%(ctype)s
""", {'cid': content_id, 'ctype': content_type}).first()
count = row.like_count if row else 0
cache.set(cache_key, count, ttl=300)
# Has the viewer liked this?
user_liked = None
if viewer_user_id:
user_key = f'user_liked:{viewer_user_id}:{content_type}:{content_id}'
user_liked = cache.get(user_key)
if user_liked is None:
exists = db.execute("""
SELECT 1 FROM Like
WHERE user_id=%(uid)s AND content_id=%(cid)s AND content_type=%(ctype)s
""", {'uid': viewer_user_id, 'cid': content_id, 'ctype': content_type}).first()
user_liked = bool(exists)
cache.set(user_key, user_liked, ttl=300)
return {'like_count': count, 'viewer_liked': user_liked}
Scaling for High-Traffic Content (Viral Posts)
-- Problem: a viral post gets 10,000 likes/second
-- UPDATE ContentStats SET like_count+1 creates a hot row in Postgres
-- Solution 1: Write-behind cache (Redis counter)
def like_with_redis_counter(user_id, content_id, content_type):
key = f'like_count:{content_type}:{content_id}'
pipe = redis.pipeline()
pipe.sadd(f'likers:{content_type}:{content_id}', user_id) # for dedup
pipe.incr(key)
pipe.execute()
# Flush to DB periodically (every 5 seconds via cron)
def flush_like_counts():
for key in redis.scan_iter('like_count:*'):
parts = key.split(':')
content_type, content_id = parts[1], parts[2]
count = redis.getset(key, 0) # reset to 0, get old value
db.execute("""
UPDATE ContentStats SET like_count=like_count+%(delta)s
WHERE content_id=%(cid)s AND content_type=%(ctype)s
""", {'delta': count, 'cid': content_id, 'ctype': content_type})
Fetching “Who Liked This”
-- List of users who liked a post (paginated, newest first)
SELECT l.user_id, u.display_name, u.avatar_url, l.created_at
FROM Like l
JOIN User u ON l.user_id = u.id
WHERE l.content_id=%(cid)s AND l.content_type=%(ctype)s
ORDER BY l.created_at DESC
LIMIT 20;
-- For counts >1M: don't fetch all likers — show count only
-- Display top 3 names + "{N} others liked this"
SELECT l.user_id FROM Like
WHERE content_id=%(cid)s
AND user_id IN (%(followed_user_ids)s) -- friends who liked it
ORDER BY created_at DESC
LIMIT 3;
Key Interview Points
- PRIMARY KEY (user_id, content_id, content_type): Enforces at-most-one like per user per item at the DB level. Use UniqueViolation on INSERT to detect “already liked” without a separate SELECT.
- Never COUNT(*) at read time: Maintain like_count in ContentStats. COUNT(*) over a billion-row Likes table is a full index scan taking seconds.
- Redis write-behind for hot rows: At 10K likes/second, even a fast Postgres UPDATE becomes a bottleneck due to row-level lock contention. Buffer in Redis; flush to Postgres in batches.
- Separate count from existence check: “How many likes?” hits ContentStats (one row). “Did this user like it?” hits Like with a (user_id, content_id, content_type) PK lookup. Both are O(1).
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”How do you implement a like toggle that prevents double-likes?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use a composite PRIMARY KEY (user_id, content_id, content_type) on the Like table. Attempt an INSERT — if the row already exists, the database throws a UniqueViolation which you catch and treat as "already liked, so unlike." This is atomic: no SELECT is needed before the INSERT. The two operations (like and unlike) share the same code path with exception handling determining the direction. Always use GREATEST(0, like_count-1) when decrementing to prevent the count going negative due to edge cases or drift.”}},{“@type”:”Question”,”name”:”How do you scale like counts for viral content without hot row contention?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A viral post receiving 10,000 likes per second creates a hot row in the ContentStats table — every UPDATE acquires a row-level lock, serializing all writes. Solution: use a Redis counter as a write-behind buffer. INCR the Redis key on each like; a background job flushes accumulated counts to the database every 5 seconds using UPDATE ContentStats SET like_count=like_count+%(delta)s. The DB sees one write per 5 seconds per content item instead of 10,000/second. Ensure the flush job is idempotent: use GETSET (read and reset atomically) to avoid double-counting.”}},{“@type”:”Question”,”name”:”How do you show "liked by 3 friends" on a post?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”At render time, given the viewer’s social graph (their list of followed/friend user IDs), query: SELECT l.user_id, u.display_name FROM Like l JOIN User u ON l.user_id=u.id WHERE l.content_id=%(cid)s AND l.user_id IN (%(friend_ids)s) ORDER BY l.created_at DESC LIMIT 3. Return up to 3 names plus the total like count for display as "Liked by Alice, Bob, and 47 others." Cache this result per (viewer_id, content_id) with TTL=1 min. For feed rendering at scale, precompute this per-viewer during feed generation rather than at read time.”}},{“@type”:”Question”,”name”:”What is the difference between a like and a reaction in data model terms?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”A like is a boolean (liked or not liked). A reaction adds a type dimension (like, love, haha, angry, sad). Data model change: add a reaction_type column to the Like table (or rename it to Reaction). To count per-type: SELECT reaction_type, COUNT(*) FROM Reaction WHERE content_id=%(cid)s GROUP BY reaction_type. Display the top 3 reaction types by count as emoji icons. The toggle logic changes: a user can switch from one reaction type to another (UPDATE the existing row) rather than only adding/removing. Prevent a user from having multiple reaction types on the same content.”}},{“@type”:”Question”,”name”:”How do you build a "posts liked by this user" profile page?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Index Like on (user_id, created_at DESC). Query: SELECT l.content_id, l.created_at FROM Like WHERE l.user_id=%(uid)s AND l.content_type=’post’ ORDER BY l.created_at DESC LIMIT 20. Then batch-fetch the post details by content_id in a single query. Use cursor pagination with created_at as the cursor for stable infinite scroll. Cache the first page per user (TTL=5 min). For privacy: respect the liked content’s visibility settings — a post liked by the user that has since been deleted or set to private should not appear in this list.”}}]}
Like system and social reaction design is discussed in Meta system design interview questions.
Like system and engagement tracking design is covered in Twitter system design interview preparation.
Like system and content reaction design is discussed in Snap system design interview guide.