Mentions and Notifications System Low-Level Design

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.

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.

Scroll to Top