Mentions and Notifications System — Low-Level Design
A mentions system parses @username references in user content, identifies the mentioned users, and triggers notifications. It must handle at-write parsing, notification fan-out, and deduplication. This design is asked at Twitter, Slack, LinkedIn, and any platform where users can reference each other.
Parsing Mentions at Write Time
import re
MENTION_PATTERN = re.compile(r'@([a-zA-Z0-9_]{1,50})')
def extract_mentions(body):
"""Extract unique usernames from content body."""
matches = MENTION_PATTERN.findall(body)
return list(set(m.lower() for m in matches)) # deduplicate, normalize
def resolve_mentions(usernames):
"""Convert usernames to user IDs."""
if not usernames:
return []
users = db.execute("""
SELECT id, username FROM User
WHERE LOWER(username) = ANY(%(names)s)
""", {'names': usernames})
return [u.id for u in users]
def process_content(author_id, content_id, body):
usernames = extract_mentions(body)
mentioned_ids = resolve_mentions(usernames)
# Store mentions for future reference (comment edits, deletion cascades)
for user_id in mentioned_ids:
db.execute("""
INSERT INTO Mention (content_id, mentioned_user_id, created_at)
VALUES (%(cid)s, %(uid)s, NOW())
ON CONFLICT DO NOTHING
""", {'cid': content_id, 'uid': user_id})
# Trigger notifications asynchronously
if mentioned_ids:
enqueue_mention_notifications(author_id, content_id, mentioned_ids)
Core Data Model
Mention
id BIGSERIAL PK
content_id BIGINT NOT NULL
content_type TEXT NOT NULL -- 'comment', 'post', 'message'
mentioned_user_id BIGINT NOT NULL
created_at TIMESTAMPTZ
UNIQUE (content_id, content_type, mentioned_user_id)
Notification
id BIGSERIAL PK
recipient_id BIGINT NOT NULL
actor_id BIGINT NOT NULL -- who triggered it
notification_type TEXT NOT NULL -- 'mention', 'reply', 'like', 'follow'
content_id BIGINT
content_type TEXT
is_read BOOLEAN DEFAULT false
created_at TIMESTAMPTZ
read_at TIMESTAMPTZ
-- Index for notification inbox
CREATE INDEX idx_notifs_recipient ON Notification(recipient_id, is_read, created_at DESC);
Notification Fan-Out Worker
def send_mention_notifications(author_id, content_id, content_type, mentioned_ids):
author = db.get(User, author_id)
content = db.get(content_type.capitalize(), content_id)
for user_id in mentioned_ids:
if user_id == author_id:
continue # Don't notify someone they mentioned themselves
# Check notification preferences
prefs = get_notification_prefs(user_id)
if not prefs.get('mentions_enabled', True):
continue
# Create in-app notification
notif = db.insert(Notification, {
'recipient_id': user_id,
'actor_id': author_id,
'notification_type': 'mention',
'content_id': content_id,
'content_type': content_type,
})
# Invalidate notification count cache
redis.delete(f'notif_count:{user_id}')
# Push to real-time channel
redis.publish(f'notifications:{user_id}', json.dumps({
'type': 'mention',
'actor': author.display_name,
'preview': content.body[:100],
'notification_id': notif.id,
}))
# Email notification (if user not online and has email enabled)
if prefs.get('email_mentions') and not is_user_online(user_id):
enqueue_email(
to=db.get(User, user_id).email,
template='mention_notification',
vars={'actor': author.display_name, 'preview': content.body[:100]},
idempotency_key=f'mention-notif-{notif.id}'
)
Notification Inbox (Read/Unread)
def get_notifications(user_id, cursor=None, limit=20):
cursor_clause = 'AND created_at < %(cursor)s' if cursor else ''
notifications = db.execute(f"""
SELECT n.*, u.display_name as actor_name, u.avatar_url as actor_avatar
FROM Notification n
JOIN User u ON n.actor_id = u.id
WHERE n.recipient_id=%(uid)s {cursor_clause}
ORDER BY n.created_at DESC
LIMIT %(limit)s
""", {'uid': user_id, 'limit': limit})
return notifications
def get_unread_count(user_id):
# Cache in Redis — invalidated on new notification or mark-all-read
count = redis.get(f'notif_count:{user_id}')
if count is not None:
return int(count)
count = db.execute("""
SELECT COUNT(*) FROM Notification
WHERE recipient_id=%(uid)s AND is_read=false
""", {'uid': user_id}).scalar()
redis.setex(f'notif_count:{user_id}', 300, count)
return count
def mark_all_read(user_id):
db.execute("""
UPDATE Notification SET is_read=true, read_at=NOW()
WHERE recipient_id=%(uid)s AND is_read=false
""", {'uid': user_id})
redis.set(f'notif_count:{user_id}', 0)
Handling Content Edits
def update_content(content_id, content_type, new_body, author_id):
old_mentions = {m.mentioned_user_id for m in
db.query("SELECT mentioned_user_id FROM Mention WHERE content_id=%(id)s",
{'id': content_id})}
new_usernames = extract_mentions(new_body)
new_ids = set(resolve_mentions(new_usernames))
added = new_ids - old_mentions
removed = old_mentions - new_ids
# Add new mentions
for uid in added:
db.execute("INSERT INTO Mention ...")
enqueue_mention_notifications(author_id, content_id, content_type, [uid])
# Remove stale mention records
for uid in removed:
db.execute("DELETE FROM Mention WHERE content_id=%(cid)s AND mentioned_user_id=%(uid)s",
{'cid': content_id, 'uid': uid})
# Note: don't delete the already-sent notifications — they're historical
Key Interview Points
- Parse at write time, not read time: Extracting mentions on every read is expensive and wasteful. Parse once on write, store in the Mention table, and use that for notifications and display.
- Deduplication in mention processing: A comment like “Hey @alice @alice look at this” should notify @alice once. Use Python set() or UNIQUE constraint on (content_id, mentioned_user_id).
- Notification preferences are per-user: Never send a notification type a user has disabled. Check preferences before creating the Notification row — don’t create it and then check.
- Cache unread count, not the list: The unread count badge appears on every page load. Cache it in Redis. The full notification list is only fetched when the user opens the inbox.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”Why parse mentions at write time instead of read time?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Read-time parsing means: for every page load of a post with 1,000 views/hour, run a regex over the body and look up each @username 1,000 times per hour. That is wasteful and slow. Write-time parsing means: run the regex once when the post is saved, resolve @usernames to user IDs, and store them in a Mention table. Subsequent reads are a simple JOIN on the Mention table — no regex, no username lookup. Additionally, write-time parsing allows immediate notification fan-out triggered by the insert, which is not possible with read-time parsing.”}},{“@type”:”Question”,”name”:”How do you handle @mentions in edited content?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”On edit, compare old and new mention sets: old_mentions = set of user IDs from Mention table for this content. new_mentions = resolve_mentions(extract_mentions(new_body)). Compute added = new – old and removed = old – new. For added users: insert new Mention rows and send notifications. For removed users: delete their Mention rows. Do NOT delete already-sent notifications — the user was notified when it was relevant. The Mention table represents the current state of the content; the Notification table is an immutable historical log.”}},{“@type”:”Question”,”name”:”How do you display @mention links in rendered content?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Store the raw body with @username text. At render time, replace @username patterns with hyperlinks using a fast regex substitution. Pre-validate that the @usernames in the rendered text are real users (use the Mention table as the source of truth — only render as links those user IDs present in the Mention table). This prevents rendering fake @mentions that point nowhere. Cache the rendered HTML with a TTL or invalidate on edit. For rich text editors (Slate, Tiptap): store @mentions as structured nodes with user_id, not just raw text, for reliable resolution.”}},{“@type”:”Question”,”name”:”How do you avoid sending duplicate notifications for the same mention?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Use a UNIQUE constraint on (content_id, content_type, mentioned_user_id) in the Mention table. The INSERT ON CONFLICT DO NOTHING pattern prevents duplicate Mention rows. Since notifications are triggered by the Mention insert, if the insert is a no-op (duplicate), no notification is triggered. For re-edits that re-add the same @mention: the Mention row already exists, so no new notification is sent. Only genuinely new mentions (first appearance in the content) generate notifications.”}},{“@type”:”Question”,”name”:”How do you implement notification preferences per user?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Store preferences in a NotificationPreference table: (user_id, notification_type, channel). Channels: in_app, email, push. Types: mention, reply, like, follow. Default: all enabled. The fan-out worker checks preferences before creating a Notification row or sending an email: if the user has disabled email for mentions, skip the email send. Check preferences at fan-out time (before creating the notification), not at delivery time — this avoids creating database records for notifications the user will never see.”}}]}
Mentions and notification system design is discussed in Twitter system design interview questions.
Mentions and team notification system design is covered in Atlassian system design interview preparation.
Mentions and professional notification system design is discussed in LinkedIn system design interview guide.