Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 053 / 100

When SSE is enough: a practical Kafka & WebSockets guide

Most 'real-time' features are one-way.

Expona Admin7 min read

Over the past fifty-three days, we've explored Kafka's power for building distributed, loosely-coupled systems and WebSockets' bidirectional magic for true real-time communication. But here's the uncomfortable truth: most features you'll build don't actually need either. When SSE is enough, forcing WebSockets or event streaming into your architecture is overengineering masquerading as sophistication. Today, we'll examine when Server-Sent Events should be your default choice, and why.

Understanding Server-Sent Events

Server-Sent Events (SSE) is the simplest possible real-time mechanism: a persistent HTTP connection where the server pushes data to the client whenever it wants. The client opens a connection, and events flow one direction only—from server to browser. No handshakes, no binary frames, no complex state management. Just text events separated by newlines.

The critical constraint is one-way communication. Your client cannot send data back through the SSE connection. But that limitation is also its strength: it forces architectural clarity. When you genuinely need bidirectional communication, you'll know it immediately. Until then, SSE keeps your codebase lean.

I've spent years watching teams over-architect real-time features. The pattern is predictable: someone suggests WebSockets, someone mentions Kafka "for scale," and suddenly you're managing consumer groups and connection pooling for a feature that could have been solved with a simple HTTP endpoint and SSE.

Notifications and Activity Feeds

User notifications are the canonical SSE use case. When a user receives a friend request, a comment reply, or a system alert, the server needs to push that update to their browser. The user doesn't send data back through the notification channel—they might acknowledge or dismiss the notification via a separate API call, but the notification itself flows one way.

Consider a collaborative document platform. User A adds a comment. The backend needs to notify User B that a new comment exists. This is pure server-to-client push, perfect for SSE.

typescript
// Backend: NestJS with SSE
import { Controller, Get, Res, Injectable } from '@nestjs/common';
import { Response } from 'express';
import { EventEmitter } from 'events';

@Injectable()
export class NotificationService extends EventEmitter {
  async notifyUser(userId: string, notification: any) {
    this.emit(`user:${userId}`, notification);
  }
}

@Controller('notifications')
export class NotificationController {
  constructor(private notificationService: NotificationService) {}

  @Get('stream')
  subscribe(@Res() res: Response, @Query('userId') userId: string) {
    res.set({
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'X-Accel-Buffering': 'no', // Disable proxy buffering
    });

    const listener = (notification: any) => {
      res.write(`data: ${JSON.stringify(notification)}\n\n`);
    };

    this.notificationService.on(`user:${userId}`, listener);

    res.on('close', () => {
      this.notificationService.removeListener(`user:${userId}`, listener);
      res.end();
    });
  }
}

The frontend is equally simple:

typescript
// Frontend: React hook for SSE
function useNotifications(userId: string) {
  const [notifications, setNotifications] = useState([]);

  useEffect(() => {
    const eventSource = new EventSource(
      `/notifications/stream?userId=${userId}`
    );

    eventSource.onmessage = (event) => {
      const notification = JSON.parse(event.data);
      setNotifications((prev) => [notification, ...prev]);
    };

    eventSource.onerror = () => {
      eventSource.close();
      // Implement reconnection logic here
    };

    return () => eventSource.close();
  }, [userId]);

  return notifications;
}

This works at scale too. With proper load balancing and sticky sessions, thousands of concurrent notifications can flow through SSE. The key is keeping listeners in-memory or backed by Redis pub/sub for distributed systems.

Progress Updates for Long Jobs

When your backend triggers an expensive operation—generating a report, processing a large dataset, transcoding video—the frontend needs periodic updates on progress. The user cannot "send" progress backwards. They can only watch and wait.

Imagine a data export feature. User clicks "Export 50,000 records as CSV." The backend enqueues a job and returns a job ID. The frontend opens an SSE connection to stream progress updates: "Processing batch 1 of 100... Processing batch 2 of 100..." until completion.

typescript
// Backend: Job progress via SSE
@Get('jobs/:jobId/progress')
streamJobProgress(
  @Param('jobId') jobId: string,
  @Res() res: Response,
) {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  // Subscribe to progress events from background worker
  const progressListener = (progress: { 
    processed: number; 
    total: number; 
    status: string 
  }) => {
    res.write(`data: ${JSON.stringify(progress)}\n\n`);
    
    if (progress.processed === progress.total) {
      res.write(`data: ${JSON.stringify({ status: 'complete' })}\n\n`);
      res.end();
    }
  };

  this.jobService.on(`progress:${jobId}`, progressListener);

  res.on('close', () => {
    this.jobService.removeListener(`progress:${jobId}`, progressListener);
  });
}

For distributed job systems, you'd pipe events from Kafka topics into SSE streams, but the principle remains: data flows one direction, and SSE handles it elegantly without the complexity of WebSocket message routing.

Live Dashboards

A dashboard displaying real-time metrics—server CPU, request latency, active users, conversion rates—is another textbook SSE scenario. The user observes metrics; they don't send metrics back through the display channel.

We built a trading dashboard at my previous role. Multiple SSE connections streamed price updates, position changes, and P&L calculations. The browser subscribed to different event streams based on which markets the trader was viewing. When the trader switched instruments, we closed one stream and opened another. Clean, efficient, predictable.

typescript
// Backend: Metrics dashboard stream
@Get('dashboard/metrics/:instrumentId')
streamMetrics(
  @Param('instrumentId') instrumentId: string,
  @Res() res: Response,
) {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  // Subscribe to real-time price feeds
  const metricsInterval = setInterval(async () => {
    const metrics = await this.marketService.getMetrics(instrumentId);
    res.write(`data: ${JSON.stringify(metrics)}\n\n`);
  }, 500); // Push every 500ms

  res.on('close', () => {
    clearInterval(metricsInterval);
    res.end();
  });
}

One-way data flow. The dashboard consumes, never produces through the SSE channel. Adding WebSockets here would introduce unnecessary complexity: message routing, connection state tracking, client-side buffering. SSE delivers the same user experience with half the code.

Streaming AI Responses

This is the latest frontier. LLM APIs like OpenAI's stream responses token-by-token over HTTP. Your backend proxies that stream to your frontend. It's one-way: tokens flow from the model through your server to the browser. The browser doesn't send data back through this channel.

I've seen teams unnecessarily complicate this with WebSockets when SSE works perfectly. Here's the cleaner approach:

typescript
// Backend: Proxy LLM stream via SSE
@Post('ai/generate')
streamAIResponse(
  @Body() { prompt }: { prompt: string },
  @Res() res: Response,
) {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  (async () => {
    try {
      const response = await openai.chat.completions.create({
        model: 'gpt-4',
        messages: [{ role: 'user', content: prompt }],
        stream: true,
      });

      for await (const chunk of response) {
        const content = chunk.choices[0]?.delta?.content || '';
        if (content) {
          res.write(`data: ${JSON.stringify({ token: content })}\n\n`);
        }
      }

      res.write(`data: ${JSON.stringify({ status: 'complete' })}\n\n`);
      res.end();
    } catch (error) {
      res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
      res.end();
    }
  })();
}

On the frontend, you progressively render tokens as they arrive. Pure streaming, one direction, no unnecessary protocol overhead.

Common Mistakes

The most frequent error is overestimating your bidirectional communication needs. Teams reach for WebSockets thinking they're more "sophisticated" or "scalable" than SSE. They're not. WebSockets add complexity in connection management, message routing, and client-side state synchronization. Use them only when you genuinely need bidirectional, low-latency messaging.

Another trap: neglecting reconnection logic. SSE connections drop. Network failures, proxy timeouts, server restarts—they all happen. Your client must detect disconnection and reconnect automatically. Implement exponential backoff and heartbeat events (`:` prefix in SSE) to keep connections alive through proxy layers.

A third mistake is treating SSE as infinitely scalable without backing it properly. If you have ten thousand connected clients, keeping EventEmitter listeners in single-process memory won't work. Use Redis pub/sub or a proper event bus to distribute listeners across your cluster.

Finally, don't ignore browser compatibility or corporate firelines. SSE works in modern browsers and most production environments, but some proxies aggressively buffer or timeout long-held connections. Test thoroughly in your deployment environment before assuming SSE will work.

Key Takeaways

  • SSE is sufficient for 80% of real-time features because most require one-way server-to-client push, not bidirectional messaging.
  • Notifications, activity feeds, progress updates, dashboards, and AI response streaming are all textbook SSE use cases.
  • SSE is simpler to implement, debug, and operate than WebSockets. Prefer simplicity until you have evidence you need bidirectional communication.
  • Implement reconnection logic and heartbeats to handle network failures gracefully.
  • For scale, back SSE streams with Redis pub/sub or proper event distribution to avoid single-process memory limits.
  • When you do need WebSockets (collaborative editing, live chat, multiplayer games), you'll know it because your use case intrinsically requires client-to-server messaging through the real-time channel.

The best architecture is the simplest one that solves your problem. Before reaching for Kafka or WebSockets, ask: does the client send data back through this channel? If the answer is no, SSE is enough.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles