TWITTER SYSTEM DESIGN

Auxiliary Systems

Notifications

How do likes, replies, and follows turn into a real-time notification without spamming the user?

KafkaWebSocketsFCM/APNsRedis
User ActionAPINotificationRowClient

Click a node to see what it does. Switch tiers above to see how the design scales.

Overview

Notifications are triggered by actions elsewhere in the system — a like, a reply, a new follower. The interesting design problem isn’t “write a row when something happens,” it’s decoupling that write from the action that triggered it, and avoiding flooding a popular user with a notification for every single one of a million replies.

API Design

GET /api/notifications?cursor=
// 200 OK
{
  "items": [
    {
      "id": "n_1",
      "type": "like",
      "actor_ids": ["u_7", "u_19"],
      "tweet_id": "1683072000000123",
      "read": false,
      "created_at": "2026-08-12T10:02:00Z"
    }
  ],
  "next_cursor": "eyJwYWdlIjoyfQ"
}
POST /api/notifications/{id}/read
// 204 No Content

actor_ids is a list, not a single field — that’s the API surfacing the aggregation window from the advanced tier directly: one notification object can represent 2 people or 200.

Database Schema

Basic tier:

CREATE TABLE notifications (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL,        -- recipient
  type        VARCHAR(20) NOT NULL,   -- 'like' | 'reply' | 'follow'
  actor_ids   BIGINT[] NOT NULL,
  tweet_id    BIGINT,
  read        BOOLEAN NOT NULL DEFAULT false,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_notifications_user_created
  ON notifications (user_id, created_at DESC);

actor_ids is an array column from the start, not a single actor_id — the schema is already shaped for aggregation before the advanced tier even introduces the aggregation window logic; the window just controls when a row gets multiple actors appended versus getting a new row.

Scaled tier — partitioned by recipient, same shape as tweets_by_author:

notifications_by_user
  partition key: user_id
  clustering key: created_at DESC
  columns: type, actor_ids, tweet_id, read

Same reasoning as Tweet Ingestion’s scaled schema: a notification feed is always read “this user’s notifications, paginated,” so partitioning by the recipient turns that into a single-partition read instead of an index scan.

Basic Approach — Synchronous Write + Polling

How it works

When an action occurs, the API directly inserts a notification row as part of handling that request. The client periodically polls an endpoint to check for new notifications.

User Action ──▶ API ──▶ Notification Row ◀── Client (polling)

Tradeoffs

  • Pro: Simple, consistent — the notification exists as soon as the triggering request completes.
  • Con: Couples the latency of the triggering action (e.g., liking a tweet) to the latency of writing a notification — a slow notification write slows down an unrelated user action.
  • Con: Polling is wasteful and not real-time; most polls return nothing new.

Scaled Approach — Event-Driven + Push Delivery

How it works

The triggering action publishes an event to a queue instead of writing the notification inline. A dedicated Notification Service consumes events, creates notification records, and delivers them via a real-time channel — a WebSocket connection for active users, or a mobile push service (APNs / FCM) for offline ones.

User Action ──▶ Queue ──▶ Notification Service ──▶ Push Channel (WS / FCM)

Tradeoffs

  • Pro: The triggering action’s request path is no longer coupled to notification delivery — publishing to a queue is fast and fire-and-forget.
  • Pro: Real push delivery instead of polling.
  • Con: Introduces eventual consistency — there’s a small delay between the action and the notification appearing.
  • Con: Doesn’t yet solve the “celebrity gets 50,000 replies in a minute” problem — that’s still 50,000 individual notification events.

Advanced Approach — Aggregation + Preference Filtering

How it works

Before delivery, events pass through an aggregation window that groups related notifications (e.g., “Alice, Bob, and 40 others liked your tweet” instead of 42 separate notifications) and a preference filter that respects per-user settings (muted threads, priority contacts). This mirrors the same push-vs-pull tiering idea from timeline fan-out: high-volume targets need batching, not raw fan-out.

Queue ──▶ Aggregation Window ──▶ Notification Service ──▶ Preference Filter ──▶ Push Channel

Tradeoffs

  • Pro: Prevents notification flooding for popular accounts and keeps the experience useful rather than noisy.
  • Pro: Preference filtering happens once, centrally, rather than being re-implemented per delivery channel.
  • Con: Aggregation windows add latency and complexity — deciding how long to wait before grouping (“did 5 more likes come in the last 30 seconds?”) is a real tuning problem.
  • Con: Delivery guarantees get harder — at-least-once delivery means the client needs idempotent handling to avoid duplicate notifications.

Tech Choices

  • Kafka — the event backbone connecting every action-producing service to the Notification Service.
  • WebSocket gateway — real-time delivery for actively connected clients.
  • APNs / FCM — push delivery for offline/mobile clients.
  • Redis — short-lived aggregation windows and dedupe state.

How to Vocalize This in an Interview

Open with the decoupling motivation — notifications shouldn’t add latency to the action that caused them — then explicitly connect the aggregation problem back to the celebrity fan-out problem from timeline generation. Naming that parallel (“this is the same push-vs-scale tension we saw earlier, just for notifications instead of timelines”) is a strong signal that you’re reasoning from first principles, not reciting a list of components.