Designing Notification System for ModusFocus

What I learned while building a reliable in-app notification pipeline

When I first thought about adding notifications to ModusFocus, the requirement appeared simple: place a bell icon in the header and show users when something important happens.

It only came out that creating bell icon was most simplest part. The real work was deciding how notifications should be created, delivered, stored, retrieved, marked as read, and observed—without slowing down the main task-management workflows or introducing unnecessary infrastructure too early.

The initial problem

For ModusFocus, we wanted an in-application notification centre that informs users about events such as:

  • A task being assigned.
  • A task being completed or cancelled.
  • Project membership changes.
  • Workspace membership and role changes.

The notification system therefore had to meet a few important requirements:

  1. It must not slow down the main business operation.
  2. Each notification must belong to a specific set of recipient.
  3. The UI must be able to retrieve new notifications efficiently.
  4. Users must be able to mark one or all notifications as read.
  5. Duplicate processing must not create duplicate notifications.
  6. Failures must be traceable across services.
  7. The first version must remain simple enough to operate as an MVP.

Considering and weighing options in depth

WebSockets and SSE in a distributed deployment

Both WebSockets and Server-Sent Events create long-lived connections between the browser and a specific application pod.

Consider a deployment with three pods:

Browser ──persistent connection──> Pod A

Task update request ──────────────> Pod B
Notification created ─────────────> Pod C

Pod A owns the browser connection, but the event may be produced or processed by Pod B or Pod C. An in-memory notification published inside Pod C is not automatically visible to Pod A.

To deliver the event to the correct browser, the pods need a shared event-distribution mechanism:

Task or membership event
        ↓
Notification created
        ↓
Redis Pub/Sub, Kafka or another message broker
        ↓
Every connected notification pod receives the event
        ↓
The pod owning the user’s connection sends it to the browser

This mechanism applies to both approaches:

  • With WebSockets, the owning pod sends the event through the user’s socket.
  • With SSE, the owning pod writes the event to the user’s open HTTP stream.

Why sticky sessions are not sufficient

A load balancer can use sticky sessions so that a browser reconnects to the same pod. However, this only helps maintain connection affinity.

It does not solve the main distribution problem: a notification can still be created by another pod or service. The connection-owning pod must still learn about that event through Redis Pub/Sub, a message broker, or a comparable shared backplane.

A production implementation would consequently require:

  • Reconnection and missed-event handling.
  • Cross-pod event distribution.
  • Broker subscription management.
  • Duplicate-event handling.
  • Pod shutdown and connection-draining behaviour.
  • Monitoring for both the persistent connections and the shared event backplane.

This was more infrastructure than the initial ModusFocus notification centre required.

How polling simplifies this

Polling keeps the frontend and task API stateless:

Browser
   ↓ periodic GET
Any task-api pod
   ↓
Shared notification store

The browser can send each polling request to any available pod. That pod reads the current unread state from the shared notification database:

GET /notifications?status=UNREAD&limit=20

There is no need to know which pod created the notification or which pod handled the previous request. The database is the durable shared source of truth.

Polling still requires deduplication, lifecycle management, and stale-response protection in the frontend, but it avoids the persistent-connection and cross-pod event-distribution layer.

The Architecture

Leave a Reply