12 KiB
Real-Time Notifications Architecture Decision
Status: POSTPONED Created: 2026-02-08 Last Updated: 2026-02-08
Context
The Legal Consent Hub requires real-time notification delivery to users. When events occur (e.g., form submissions, approvals, comments), affected users should receive notifications immediately without refreshing the page.
Constraint: Stateless Backend
The backend must be stateless for horizontal scaling. Multiple backend instances run behind a load balancer, and any instance must be able to handle any request.
Current Implementation
Location: src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/notification/
Files
| File | Purpose |
|---|---|
SseNotificationEmitterRegistry.kt |
In-memory registry of SSE connections |
NotificationEventListener.kt |
Listens for NotificationCreatedEvent and triggers SSE delivery |
NotificationCreatedEvent.kt |
Spring application event for new notifications |
NotificationService.kt |
Creates notifications and publishes events |
NotificationController.kt |
REST endpoints including SSE stream endpoint |
How It Works
- Frontend opens SSE connection to
/api/notifications/stream - Backend stores
SseEmitterinSseNotificationEmitterRegistry(in-memoryCopyOnWriteArrayList) - When notification created,
NotificationServicepublishesNotificationCreatedEvent NotificationEventListenerreceives event, uses registry to send SSE to matching users- Registry filters by
userId,organizationId, androlesto route notifications
Current Code (SseNotificationEmitterRegistry.kt)
@Service
class SseNotificationEmitterRegistry {
// IN-MEMORY STORAGE - THIS IS THE PROBLEM
private val connections = CopyOnWriteArrayList<SseConnectionContext>()
fun registerEmitter(userId: String, organizationId: String, userRoles: List<String>): SseEmitter {
val emitter = SseEmitter(TIMEOUT_MS)
val context = SseConnectionContext(emitter, userId, organizationId, userRoles)
connections.add(context)
// ... cleanup handlers
return emitter
}
fun sendToUser(userId: String, organizationId: String, event: SseEmitter.SseEventBuilder) {
connections.filter { it.userId == userId && it.organizationId == organizationId }
.forEach { it.emitter.send(event) }
}
// Also: sendToRoles(), sendToOrganization()
}
The Problem
In-memory storage breaks with multiple instances.
┌─────────────────────────────────────────────────────────────┐
│ Load Balancer │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Instance A │ │ Instance B │
│ │ │ │
│ connections = [ │ │ connections = [ │
│ User1-Emitter, │ │ User2-Emitter, │
│ User3-Emitter │ │ User4-Emitter │
│ ] │ │ ] │
└─────────────────────────┘ └─────────────────────────┘
Scenario:
- User1 connects via SSE → routed to Instance A → emitter stored in A's memory
- User2 connects via SSE → routed to Instance B → emitter stored in B's memory
- User1 creates notification for User2
- Instance A handles the request, tries to send SSE to User2
- FAILURE: User2's emitter is in Instance B, not Instance A
- User2 never receives the notification
Options
Option 1: Redis Pub/Sub
How it works:
- When notification created, publish event to Redis channel
- All backend instances subscribe to this channel
- Each instance forwards matching events to its local SSE connections
┌──────────────┐ publish ┌─────────────┐
│ Instance A │ ───────────────► │ Redis │
│ (creates │ │ Pub/Sub │
│ notification) └─────────────┘
└──────────────┘ │
│ subscribe (all instances)
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│Instance A│ │Instance B│ │Instance C│
│(send to │ │(send to │ │(send to │
│local SSE)│ │local SSE)│ │local SSE)│
└──────────┘ └──────────┘ └──────────┘
| Pros | Cons |
|---|---|
| Low latency (~1-5ms) | Requires Redis infrastructure |
| Battle-tested pattern | Additional operational complexity |
Spring has good support (spring-boot-starter-data-redis) |
Another service to monitor/maintain |
| Can reuse Redis for caching later |
Implementation effort: ~2-4 hours
Dependencies to add:
implementation("org.springframework.boot:spring-boot-starter-data-redis")
Option 2: PostgreSQL LISTEN/NOTIFY
How it works:
- Use Postgres native pub/sub mechanism
- On notification insert, trigger sends NOTIFY
- All backend instances LISTEN on the channel
- Each instance forwards to its local SSE connections
-- Trigger function
CREATE FUNCTION notify_new_notification() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('new_notification', row_to_json(NEW)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger on insert
CREATE TRIGGER notification_insert_trigger
AFTER INSERT ON notifications
FOR EACH ROW EXECUTE FUNCTION notify_new_notification();
| Pros | Cons |
|---|---|
| No new infrastructure (already have Postgres) | Slightly higher latency (~5-20ms) |
| Simple conceptually | Requires manual JDBC listener setup |
| Database is source of truth | Less common pattern, fewer examples |
| Works even if notification created by SQL directly | Connection pool considerations |
Implementation effort: ~3-5 hours
No additional dependencies (uses existing JDBC driver)
Option 3: Message Queue (RabbitMQ / Kafka)
How it works:
- Publish notification events to queue/topic
- All instances consume from the queue
- Each instance forwards to local SSE connections
| Pros | Cons |
|---|---|
| Very reliable, durable messages | Significant new infrastructure |
| Can handle high throughput | Overkill for notification use case |
| Good for future event-driven architecture | Higher operational complexity |
| Supports complex routing | Longer setup time |
Implementation effort: ~4-8 hours
Dependencies:
// RabbitMQ
implementation("org.springframework.boot:spring-boot-starter-amqp")
// OR Kafka
implementation("org.springframework.kafka:spring-kafka")
Option 4: Client-Side Polling (Remove SSE)
How it works:
- Remove SSE entirely
- Frontend polls
/api/notifications?since={timestamp}every N seconds - Completely stateless, no server-side connection tracking
// Frontend polling
setInterval(async () => {
const notifications = await fetch(`/api/notifications?since=${lastCheck}`)
lastCheck = Date.now()
// Update UI with new notifications
}, 10000) // Poll every 10 seconds
| Pros | Cons |
|---|---|
| Simplest solution | Not real-time (10-30s delay) |
| Truly stateless | Higher database load |
| No new infrastructure | More API requests |
| Easy to implement | Worse UX for time-sensitive notifications |
| Works through all proxies/firewalls |
Implementation effort: ~1-2 hours (mostly removing SSE code)
Option 5: WebSocket with External STOMP Broker
How it works:
- Use Spring WebSocket with STOMP protocol
- External message broker (RabbitMQ/ActiveMQ) handles message routing
- Clients subscribe to user-specific topics
| Pros | Cons |
|---|---|
| Full-duplex communication | Requires message broker |
| Rich protocol (STOMP) | More complex than SSE |
| Spring has excellent support | WebSocket connection management |
| Can do request-response patterns | Some proxies don't support WebSocket well |
Implementation effort: ~4-6 hours
Comparison Matrix
| Criteria | Redis Pub/Sub | Postgres NOTIFY | Message Queue | Polling | WebSocket+STOMP |
|---|---|---|---|---|---|
| Latency | ~1-5ms | ~5-20ms | ~5-50ms | 10-30s | ~1-5ms |
| New Infrastructure | Redis | None | RabbitMQ/Kafka | None | RabbitMQ/ActiveMQ |
| Implementation Effort | Low | Medium | High | Very Low | Medium |
| Operational Complexity | Low | Low | High | None | Medium |
| Scalability | Excellent | Good | Excellent | Limited | Excellent |
| Spring Support | Excellent | Manual | Excellent | N/A | Excellent |
Recommendation
For this project, the top candidates are:
- PostgreSQL LISTEN/NOTIFY - Already have Postgres, no new infra, acceptable latency
- Redis Pub/Sub - If Redis is added for caching anyway, use it for this too
- Polling - If real-time isn't critical, simplest solution
Avoid: Message Queue (overkill), WebSocket+STOMP (unnecessary complexity)
Why Postponed
- Current state works for single instance - Development and testing can proceed
- No immediate production multi-instance deployment - Decision can wait
- Infrastructure decisions pending - Unknown if Redis will be used for other purposes
- Need to evaluate real-time requirements - How critical is sub-second notification delivery?
Questions to Answer Before Deciding
- Will Redis be used for caching or session storage?
- How critical is real-time delivery? (Is 10-30s polling acceptable?)
- What is the expected notification volume?
- What is the deployment target? (Kubernetes, Docker Swarm, VMs?)
How to Resume This Work
When ready to implement multi-instance support:
- Answer the questions above
- Choose an option based on infrastructure and requirements
- Implementation steps:
- Keep
SseNotificationEmitterRegistryfor local emitter management - Add cross-instance event distribution layer
- Modify
NotificationEventListenerto publish to chosen channel - Add subscriber that receives events and calls registry's send methods
- Keep
Key insight: The local SSE connection management stays the same. Only the event distribution mechanism changes.
Related Files
SseNotificationEmitterRegistry.kt- Current in-memory implementationNotificationEventListener.kt- Event handling logicNotificationCreatedEvent.kt- Event payloadNotificationService.kt- Notification creationNotificationController.kt- SSE endpoint