# Low Level Design: In-App Notification Center

Source: https://www.techinterview.org/post/3233471820/lld-notification-center/
Updated: 2026-06-30 · techinterview.org

## What Is an In-App Notification Center?

An in-app notification center is the bell-icon inbox found in products like GitHub, LinkedIn, and Slack. Users open it to see a paginated list of recent notifications — likes, mentions, replies, system alerts — and can mark them read individually or in bulk. At scale this system must handle millions of users, each with hundreds of unread notifications, while supporting real-time delivery and fast reads.

## Requirements

### Functional

- Generate notifications for events (mention, like, follow, comment, system alert).

- Store per-user notification lists with read/unread state.

- Display notifications in reverse-chronological order with infinite scroll / cursor pagination.

- Mark a single notification as read.

- Bulk mark-all-as-read.

- Real-time delivery to online users.

- Unread badge count visible in the nav bar.

### Non-Functional

- Read latency < 100 ms for the first page.

- Delivery latency < 2 s for online users.

- Support 50 M DAU, each receiving up to 100 notifications per day.

- Notifications retained for 90 days.

## Core Entities


```
notifications
  id            BIGINT PK
  recipient_id  BIGINT   -- the user who sees it
  actor_id      BIGINT   -- user who triggered the event (nullable for system)
  type          VARCHAR  -- like | comment | mention | follow | system
  entity_type   VARCHAR  -- post | comment | user
  entity_id     BIGINT
  is_read       BOOLEAN  DEFAULT false
  created_at    TIMESTAMP

notification_counts          -- denormalized unread count cache
  user_id       BIGINT PK
  unread_count  INT      DEFAULT 0
  updated_at    TIMESTAMP
```


## API Design


```
GET  /v1/notifications?cursor=&limit=20
     Response: { items: [...], next_cursor, unread_count }

POST /v1/notifications/{id}/read
     Response: 204

POST /v1/notifications/read-all
     Response: 204

WebSocket ws://api/notifications/stream
     Server pushes: { type: new_notification, payload: {...} }
```


## Notification Storage Strategy

### Option A: Fan-Out on Write (Push Model)

When an event fires, the producer immediately writes one row per recipient into the notifications table. Reads are cheap: a simple indexed scan on (recipient_id, created_at DESC). This is the right default for most products.

**Tradeoff:** A user with 10 M followers (celebrity problem) triggers 10 M writes on a single event. Mitigate by capping fan-out (skip offline users, use async workers) or switching celebrities to fan-out on read.

### Option B: Fan-Out on Read (Pull Model)

Store only the event. At read time, look up who the user follows, fetch recent events from those actors, and merge. Read is expensive; rarely used alone except for very high-follower accounts.

### Hybrid (Recommended at Scale)

Fan-out on write for regular users. Fan-out on read for users with follower count above a threshold (e.g., 1 M). At read time, merge the pre-written notifications with on-demand celebrity events.

## Database Indexing


```
-- Primary read path
CREATE INDEX idx_notif_recipient_time
  ON notifications (recipient_id, created_at DESC);

-- For unread-only filter
CREATE INDEX idx_notif_recipient_unread
  ON notifications (recipient_id, is_read, created_at DESC);
```

With 90-day retention and 50 M DAU at 100 notifications/day, the table holds ~450 B rows. Partition by recipient_id (hash) or by created_at range to keep index sizes manageable. Consider moving rows older than 30 days to cold storage (S3 + Athena) and only serving the hot partition from PostgreSQL or Cassandra.

## Unread Count

Never run `SELECT COUNT(*) WHERE is_read = false` on every page load — it table-scans. Instead maintain a denormalized counter in the notification_counts table.


```
-- On insert
UPDATE notification_counts
  SET unread_count = unread_count + 1
  WHERE user_id = :recipient_id;

-- On mark-read (single)
UPDATE notification_counts
  SET unread_count = GREATEST(0, unread_count - 1)
  WHERE user_id = :user_id;

-- On mark-all-read
UPDATE notifications SET is_read = true WHERE recipient_id = :user_id AND is_read = false;
UPDATE notification_counts SET unread_count = 0 WHERE user_id = :user_id;
```

Cache the count in Redis with a short TTL (10 s) keyed as `notif:unread:{user_id}` so the nav bar badge reads from cache, not the DB.

## Cursor Pagination

Offset pagination breaks on large offsets and is inconsistent when new rows are inserted. Use a cursor based on the last seen notification id.


```
-- First page
SELECT * FROM notifications
WHERE recipient_id = :uid
ORDER BY created_at DESC, id DESC
LIMIT 21;  -- fetch 21 to know if a next page exists

-- Subsequent pages (cursor = last id from previous page)
SELECT * FROM notifications
WHERE recipient_id = :uid
  AND (created_at, id) < (:cursor_ts, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 21;
```

Encode the cursor as a base64 JSON blob so it is opaque to clients: {ts: 2024-01-15T10:00:00Z, id: 98765}.

## Real-Time Delivery via WebSocket


```
Client  --WS connect-->  Gateway (stateful)
Gateway --subscribe-->   Redis Pub/Sub  channel: notif:{user_id}
Producer --publish-->    Redis Pub/Sub  channel: notif:{user_id}
Redis   --fan-out-->     all Gateway pods subscribed to that channel
Gateway --push frame-->  Client
```

Steps on event occurrence:

- Event producer writes the notification row(s) to DB (async worker).

- Producer publishes a lightweight message to Redis channel `notif:{recipient_id}`.

- The Gateway pod holding the user WebSocket receives it and pushes a JSON frame to the client.

- Client appends the notification to the top of the list and increments the badge count locally.

If the user is offline, no WebSocket exists and Redis discards the pub/sub message — the notification is still persisted in the DB and will appear on next load.

## Bulk Mark-All-As-Read

Naively updating millions of rows synchronously is too slow. Use a **watermark pattern**:


```
user_read_watermarks
  user_id       BIGINT PK
  read_before   TIMESTAMP  -- all notifications before this are treated as read
  updated_at    TIMESTAMP
```

On mark-all-read, set `read_before = NOW()`. At read time, apply: `is_read OR created_at < read_before`. This makes the DB write O(1) instead of O(n). New notifications arriving after the watermark still track individual is_read state.

## Notification Grouping / Aggregation

Instead of showing 47 separate like notifications, group them: Alice, Bob, and 45 others liked your post. Implement by storing a group_key on each notification row:


```
group_key  VARCHAR  -- e.g., like:post:12345
```

At read time, GROUP BY group_key and aggregate actor names. Alternatively, maintain a separate aggregated_notifications table updated by a background job every 60 s. The latter trades freshness for read simplicity.

## Scalability Summary

| Concern | Solution |
| --- | --- |
| Write throughput | Async workers, partitioned DB |
| Read latency | Composite index, Redis count cache |
| Celebrity fan-out | Hybrid push/pull model |
| Real-time delivery | WebSocket + Redis Pub/Sub |
| Unread count | Denormalized counter + Redis cache |
| Bulk read | Watermark pattern |
| Storage | 90-day TTL, cold-tier archival |

## Common Interview Follow-Ups

- **How do you guarantee exactly-once delivery?** Idempotency key on the notification row keyed on (actor_id, type, entity_id, recipient_id, day). Insert with ON CONFLICT DO NOTHING.

- **Push notifications (mobile)?** After DB write, enqueue a job to call APNs / FCM. Separate from the in-app center but uses the same notification record.

- **Multi-device?** WebSocket gateway tracks all sessions per user. Publish once to Redis; all devices receive the frame.

- **Privacy?** When the source entity (post, comment) is deleted, soft-delete or tombstone the notification rather than hard-delete to preserve referential clarity.

See also: [Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering](https://www.techinterview.org/post/3233460272/meta-interview-guide-2026-facebook-instagram-whatsapp-engineering/)

See also: [Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence](https://www.techinterview.org/post/3233460274/netflix-interview-guide-2026-streaming-architecture-recommendation-systems-and-engineering-excellence/)

See also: [Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering](https://www.techinterview.org/post/3233460275/scale-ai-interview-guide-2026-data-infrastructure-rlhf-pipelines-and-ml-engineering/)
