Designing a Scalable Notification Service for Instagram

Arpit Bhayani

Arpit Bhayani

Apr 01, 2021 • 10 min read

Play

1. Introduction: The Notification Fanout Problem

Designing a notification system for platforms like Instagram, Twitter, or YouTube presents unique distributed systems challenges. Consider a celebrity like Sachin Tendulkar or Cristiano Ronaldo posting an update on Instagram. With tens of millions of followers, a single write action triggers a requirement to deliver tens of millions of distinct notification messages within a reasonable time window.

Delivering notifications at this scale in strict real-time is operationally expensive and can bring down downstream services if not carefully architected. The system must navigate complex constraints:

  • Throughput and Backpressure: Ingesting platform events without letting massive fanouts stall incoming streams.
  • Channel Diversity: Delivering push notifications (APNs, FCM), in-app notifications, SMS, and emails.
  • User Experience & Rate Limiting: Avoiding notification spam, which directly correlates with application uninstalls.
  • Network I/O Dominance: Handling hundreds of thousands of concurrent external network calls to third-party push gateways.

2. Product-Engineering Synergy: The “Bell Icon” Strategy

High-scale engineering problems are often mitigated before they hit the database layer through clever product mechanics. A prime example is the “Bell Icon” (popularized by YouTube and adopted across social platforms).

Creator Posts Update (20M Followers)

         ├──► 1M "Bell Icon" Followers ──► Immediate P0/P1 High-Priority Delivery

         └──► 19M Standard Followers    ──► Batched / Delayed / Algorithmic Feed Delivery

Why the Bell Icon Matters for Infrastructure

  • Explicit Intent Partitioning: If an account has 20 million followers, sending 20 million push notifications concurrently upon every post creates massive thundering herd problems.
  • Selective Fanout: By asking users to opt-in via a bell icon, the platform identifies “loyal” or “high-affinity” followers. If only 1 million of those 20 million click the bell icon, the immediate, hard-real-time push requirement is reduced by 95%.
  • Graceful Degradation: The platform can dispatch instant alerts to the 1 million bell subscribers while lazily notifying or purely relying on in-app feed placement for the remaining 19 million.

(A parallel can be drawn to viral social trends, such as the “10-Year Challenge”. From an engineering standpoint, these prompts serve as rich data-collection primitives to train demographic and image-aging ML models.)


3. Core Functional Capabilities

A robust notification platform must handle four distinct operational workflows:

  1. Push Notifications: Ephemeral, out-of-app alerts dispatched via Apple Push Notification service (APNs) or Firebase Cloud Messaging (FCM).
  2. In-App Notifications: Persistent records displayed within the user’s in-app notification center/tray. These must be stored in a persistent database.
  3. Notification Aggregation: Merging multiple related actions into a single coherent message (e.g., “Alice and 42 others liked your photo” instead of 43 distinct alerts).
  4. Notification Decider (Frequency Capping & Preferences): Market studies show that users who receive excessive notifications per day have high churn and app-uninstall rates. The system must evaluate whether a notification should be delivered before dispatching it.

4. Payload & Schema Design: Keeping the Client Dumb

A common anti-pattern is hardcoding notification layouts, icons, or navigation paths into the mobile application. If a product manager wants to alter the notification structure or route, doing so would require an entire mobile app release cycle.

The Generic Payload Pattern

All rendering logic and routing decisions must be backend-driven. The client remains a dumb rendering engine.

CREATE TABLE in_app_notifications (
    notification_id   UUID PRIMARY KEY,
    user_id           BIGINT NOT NULL,
    actor_id          BIGINT NOT NULL,
    title             VARCHAR(255) NOT NULL,
    description       TEXT NOT NULL,
    action_url        VARCHAR(512) NOT NULL, -- Deep link or route key
    metadata          JSONB,                 -- Dynamic UI fields (thumbnails, badges, icons)
    status            VARCHAR(32) NOT NULL,  -- READ, UNREAD, ARCHIVED
    created_at        TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

Dynamic Metadata

Instead of creating separate database columns for profile_photo_url, media_preview_url, or badge_type, use an extensible metadata field (e.g., JSONB in PostgreSQL):

{
  "notification_id": "d3b07384-d113-494a-a032-47530689b9d1",
  "user_id": 10928374,
  "title": "New Post from Sachin",
  "description": "Sachin Tendulkar shared a new post.",
  "action_url": "instagram://post/982341124",
  "metadata": {
    "avatar_url": "https://cdn.instagram.com/u/10/avatar.jpg",
    "thumbnail_url": "https://cdn.instagram.com/p/982341124/thumb.jpg",
    "render_style": "COLLAPSED_THUMBNAIL"
  }
}
  • Action URLs: The client reads action_url and dispatches internal deep links (e.g., navigating directly to the specific post view or profile).
  • Deployment Agility: If the visual style, notification copy, or click action changes, updates are deployed instantly on the backend without requiring a client-side app update.

5. End-to-End System Architecture

[Post Service] ──► [Kafka Topic: Platform Events]


                   [Notification Service / Brain]
                   ├── Read User Preferences
                   ├── Frequency Capping / Filtering
                   └── Fanout Engine (Fetch Followers)

              ┌──────────────┼──────────────┐
              ▼              ▼              ▼
          [P0 Queue]    [P1 Queue]     [P2 Queue]
              │              │              │
              ▼              ▼              ▼
        [Workers P0]   [Workers P1]   [Workers P2]
              │              │              │
     ┌────────┴──────────────┼──────────────┴────────┐
     ▼                       ▼                       ▼
 [APNs / FCM]            [SendGrid]               [Twilio]

The Execution Stages

  1. Event Ingestion: Microservices emit domain events (e.g., PostCreated, UserLikedComment) to an append-only event stream (Apache Kafka or AWS Kinesis).
  2. The Notification Brain (Decider): Consumes events and evaluates:
    • User Settings: Did the recipient mute this creator or disable push notifications?
    • Rate Limits: Has the recipient already received their maximum allocation of alerts today?
    • Engagement Context: Is the recipient currently active in the app? (If active, an in-app toast suffices; do not send an external APNs push).
  3. Partitioned Prioritization Queues: Decouples event intake from external dispatching using priority tiers (P0, P1, P2).
  4. Dispatch Workers: Network-optimized consumers pull jobs from priority queues and call third-party gateways (APNs, FCM, SMS, Email).

6. Resolving the Stream Bottleneck: Kafka vs. Worker Queues

A critical mistake when building notification pipelines is executing fanout or network dispatching directly inside Kafka consumers.

The Kafka Limitation

  • In Apache Kafka, the degree of consumer concurrency is bounded by the number of partitions in the topic. A topic with 32 partitions can have at most 32 active consumer threads in a consumer group.
  • If a consumer receives a PostCreated event for an account with 20M followers and begins iterating through the followers list or making HTTP calls, that consumer thread can stall for several minutes.
  • Result: Extreme consumer lag, backpressure, partition heartbeat timeouts, and rebalance storms across the Kafka consumer group.
┌─────────────────────────────────────────────────────────────┐
│                     Apache Kafka Topic                      │
│                 (Fixed Partition Concurrency)               │
└──────────────────────────────┬──────────────────────────────┘
                               │ Fast Read (Event Ingestion)

┌─────────────────────────────────────────────────────────────┐
│              Notification Fanout Ingestor                   │
│      (Reads event, fetches follower chunks, pushes jobs)    │
└──────────────────────────────┬──────────────────────────────┘
                               │ Pushes Chunked Dispatch Jobs

┌─────────────────────────────────────────────────────────────┐
│                  AMQP / SQS / Redis Queues                  │
│             (Dynamic, Fine-Grained Concurrency)             │
└──────────────────────────────┬──────────────────────────────┘
                               │ High Fanout Concurrency

┌─────────────────────────────────────────────────────────────┐
│               Stateless Dispatch Worker Fleet               │
│            (Autoscaled based on Queue Backlog)              │
└─────────────────────────────────────────────────────────────┘

The Decoupled Architecture

  1. Kafka Handles Event Ingestion: Kafka’s role is strictly to act as an immutable, ordered event log between microservices.
  2. Fast Ingestion Consumer: The Kafka consumer reads the event at maximum speed, determines the follower IDs (or batches of follower IDs), and writes lightweight jobs to a secondary queue (such as RabbitMQ, AWS SQS, or Redis-backed queues).
  3. Dynamic Fanout Fleet: Unlike Kafka partitions, worker queues allow thousands of independent worker threads to pop jobs concurrently without partition assignment locks.

7. Priority Queues & SLA-Driven Delivery

Not all notifications have equal business value. Delivering an authentication code or a direct message notification should never be delayed because a high-follower post is currently saturating the queue.

Priority TierNotification CategoryTarget SLAScaling Policy
P02FA Codes, Security Alerts, Direct Messages< 5 secondsHigh over-provisioning; low auto-scale trigger thresholds
P1Direct Tags, Mentions, Post Comments< 30 secondsModerate auto-scaling buffer
P2Followed Creator Uploaded Post / Video< 5 - 15 minutesStandard dynamic scaling based on queue depth
P3System Recommendations, “You may know X”< 1 - 2 hoursLow priority; processes off-peak or when higher queues are idle

Auto-Scaling Workers Based on SLA

Worker pools are decoupled per queue. Instead of scaling based on CPU or memory utilization, the worker fleet scales based on Queue Backlog (Queue Depth) and Time-in-Queue:

Target Workers=Queue Message Count×Avg Processing TimeSLA Target\text{Target Workers} = \left\lceil \frac{\text{Queue Message Count} \times \text{Avg Processing Time}}{\text{SLA Target}} \right\rceil

Using AWS SQS and CloudWatch, or RabbitMQ metrics, an autoscale policy provisions more worker instances when the arrival rate outpaces the target SLA.


8. Dispatch Worker Architecture: The Network I/O Profile

Notification dispatching is rarely compute-heavy. It is almost entirely Network I/O bound.

Worker Thread ──► [TLS Handshake / HTTP POST] ──► APNs Gateway   (Wait: 50-150ms)
Worker Thread ──► [TLS Handshake / HTTP POST] ──► FCM Gateway    (Wait: 50-150ms)
Worker Thread ──► [TLS Handshake / HTTP POST] ──► Twilio API     (Wait: 200-400ms)

Engineering Implications for Worker Runtimes

  • Asynchronous / Non-Blocking Runtimes: Using standard synchronous blocking threads (e.g., synchronous Python threads) wastes significant compute resources, as threads spend most of their life cycle idling on network sockets.
  • Optimal Tech Stacks: Go (goroutines), Node.js (event loop), or Java/Kotlin (Project Loom virtual threads / Netty) are ideal. Thousands of lightweight concurrent tasks can run per container without excessive memory overhead.
  • Connection Pooling: Workers must maintain persistent HTTP/2 client connections to APNs and FCM to bypass repetitive TLS handshaking overhead on every push delivery.

9. Notification Aggregation

Flooding a user with dozens of standalone notifications for every like degrades app engagement. The system needs to aggregate events.

[Raw Event 1: User B liked Post X] ──┐
[Raw Event 2: User C liked Post X] ──┼──► [Aggregation Engine] ──► "User B, User C and 10 others
[Raw Event 3: User D liked Post X] ──┘      (Tumbling Window)        liked your photo."

Aggregation Strategies

  1. In-App Notification Mutation: Instead of inserting a new row per like, the in-app record for Post X is updated in place. A single database row tracks like_count and the latest actor IDs (actor_ids: [User B, User C]), dynamically altering the rendered message.
  2. Tumbling / Sliding Windows in Stream Processing: Before a push alert is generated, events pass through an intermediate windowing layer (e.g., Apache Flink or a Redis hash bucket). If 20 likes occur within a 5-minute sliding window, only one aggregated push notification is dispatched.

10. Summary Architecture Checklist

  • Decouple Event Streaming from Work Queues: Use Kafka for reliable log ingestion, but fan out jobs into dynamic task queues (SQS, RabbitMQ) to bypass partition concurrency limits.
  • Keep the Client Generic: Store deep links (action_url) and UI styling in a dynamic payload/metadata schema so backend business logic can evolve without client updates.
  • Implement a Dedicated Decision Layer: Filter, throttle, and verify user preferences before queuing expensive network operations.
  • SLA-Segregated Priorities: Separate P0 (security, DMs) from P2/P3 (marketing, creator uploads) across isolated queues to protect critical communication channels.
  • Optimize for I/O Concurrency: Run non-blocking, async I/O worker fleets equipped with HTTP/2 persistent connection pooling targeting push providers.
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses