Back to blog
real-timewebsocketsssearchitecturescalingsaas

Real-time features in your SaaS: from polling to WebSockets

By SaaS Masters7 maart 202611 min read
Real-time features in your SaaS: from polling to WebSockets

Imagine this: a user edits a document, and their colleague sees the change appear instantly. A dashboard that automatically updates when new data comes in. A notification system that pushes alerts without requiring a page refresh. These aren't luxury features anymore — it's what users expect from modern SaaS applications.

In this article, we'll take a deep dive into the technologies behind real-time functionality, when to choose which approach, and how to implement this at scale without making your architecture unnecessarily complex.

Why real-time matters for SaaS

Real-time features dramatically increase the perceived performance of your application. Research from Google shows that users expect feedback on their actions within 100ms. But it goes beyond speed:

  • Higher engagement: Users stay active longer when data updates live
  • Better collaboration: Teams can work simultaneously in the same environment
  • Lower churn: Applications that feel "alive" are less likely to be replaced
  • Operational value: Real-time dashboards and alerts enable users to react faster

According to an analysis by Intercom, SaaS products with real-time collaborative features have 23% higher retention than products without.

The three pillars: Polling, SSE, and WebSockets

1. Short Polling — the simple start

With short polling, the client periodically sends an HTTP request to the server to check if there's new data.

// Simple polling implementation
function startPolling(endpoint: string, interval: number = 5000) {
  const poll = async () => {
    try {
      const response = await fetch(endpoint);
      const data = await response.json();
      handleUpdate(data);
    } catch (error) {
      console.error('Polling error:', error);
    }
  };

  return setInterval(poll, interval);
}

When polling is the right choice:

  • Prototyping and MVP phase
  • Data that changes at most every 30+ seconds
  • Situations where infrastructure doesn't support persistent connections
  • Simple status checks (has the payment been processed?)

Drawbacks:

  • Inefficient: most requests return no new data
  • Latency: updates only become visible after the next poll interval
  • Server load scales linearly with the number of users

2. Server-Sent Events (SSE) — the underrated middle ground

SSE is a simple protocol where the server can push data to the client via a long-lived HTTP connection. It's unidirectional (server → client), but for many use cases, that's exactly what you need.

// Next.js API route with SSE
// app/api/events/route.ts
export async function GET(request: Request) {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    start(controller) {
      const send = (data: object) => {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
        );
      };

      // Listen to database changes or message queue
      const unsubscribe = eventBus.subscribe('updates', (event) => {
        send(event);
      });

      // Heartbeat to keep connection alive
      const heartbeat = setInterval(() => {
        controller.enqueue(encoder.encode(': heartbeat\n\n'));
      }, 30000);

      request.signal.addEventListener('abort', () => {
        unsubscribe();
        clearInterval(heartbeat);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}
// Client-side SSE
const eventSource = new EventSource('/api/events');

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  updateUI(data);
};

eventSource.onerror = (error) => {
  // EventSource reconnects automatically
  console.log('SSE connection being restored...');
};

Advantages of SSE:

  • Automatic reconnect built into the browser
  • Works over standard HTTP (no special proxy configuration)
  • Much simpler than WebSockets for unidirectional communication
  • Supports event types and IDs for at-least-once delivery

Ideal for:

  • Live dashboards and monitoring
  • Notification systems
  • Feed updates (social, activity)
  • Progress indicators for long-running tasks
  • AI streaming responses (think ChatGPT-like interfaces)

3. WebSockets — full-duplex communication

WebSockets provide bidirectional communication over a single TCP connection. This is the most powerful option, but also the most complex.

// Server-side WebSocket with ws library
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });

// Room-based broadcasting
const rooms = new Map<string, Set<WebSocket>>();

wss.on('connection', (ws, request) => {
  const userId = authenticateFromRequest(request);
  const roomId = extractRoomId(request.url);

  // Join room
  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  rooms.get(roomId)!.add(ws);

  ws.on('message', (raw) => {
    const message = JSON.parse(raw.toString());

    switch (message.type) {
      case 'cursor_move':
        broadcastToRoom(roomId, {
          type: 'cursor_update',
          userId,
          position: message.position,
        }, ws);
        break;

      case 'document_edit':
        const result = applyEdit(message.edit);
        broadcastToRoom(roomId, {
          type: 'document_change',
          edit: result,
          userId,
        }, ws);
        break;
    }
  });

  ws.on('close', () => {
    rooms.get(roomId)?.delete(ws);
    broadcastToRoom(roomId, { type: 'user_left', userId });
  });
});

function broadcastToRoom(roomId: string, data: object, exclude?: WebSocket) {
  rooms.get(roomId)?.forEach((client) => {
    if (client !== exclude && client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify(data));
    }
  });
}

Ideal for:

  • Collaborative editing (Google Docs-like)
  • Chat and messaging
  • Multiplayer functionality
  • Live cursor tracking
  • Any situation where the client needs to send data to the server in real-time

Decision matrix: which technology when?

CriterionPollingSSEWebSockets
DirectionClient → ServerServer → ClientBidirectional
ComplexityLowMediumHigh
LatencyHigh (interval)LowVery low
ScalabilityModerateGoodComplex
ReconnectManualAutomaticManual
Binary dataNoNoYes
HTTP/2 compatibleYesYesNo (upgrade)

Rule of thumb: Start with SSE unless you need bidirectional communication. Polling for MVPs. WebSockets only when truly needed.

Scaling up: the pub/sub layer

The biggest challenge with real-time at scale: if you're running multiple server instances (and you are), how do you ensure an event on server A also reaches clients on server B?

The solution is a pub/sub layer between your servers.

// Redis pub/sub as backbone for multi-instance real-time
import Redis from 'ioredis';

class RealTimeBroker {
  private publisher: Redis;
  private subscriber: Redis;
  private localSubscriptions = new Map<string, Set<(data: any) => void>>();

  constructor(redisUrl: string) {
    this.publisher = new Redis(redisUrl);
    this.subscriber = new Redis(redisUrl);

    this.subscriber.on('message', (channel, message) => {
      const handlers = this.localSubscriptions.get(channel);
      if (handlers) {
        const data = JSON.parse(message);
        handlers.forEach(handler => handler(data));
      }
    });
  }

  async publish(channel: string, data: object) {
    await this.publisher.publish(channel, JSON.stringify(data));
  }

  subscribe(channel: string, handler: (data: any) => void) {
    if (!this.localSubscriptions.has(channel)) {
      this.localSubscriptions.set(channel, new Set());
      this.subscriber.subscribe(channel);
    }
    this.localSubscriptions.get(channel)!.add(handler);

    return () => {
      this.localSubscriptions.get(channel)?.delete(handler);
      if (this.localSubscriptions.get(channel)?.size === 0) {
        this.subscriber.unsubscribe(channel);
        this.localSubscriptions.delete(channel);
      }
    };
  }
}

Alternatives to Redis pub/sub

  • NATS: Lightweight, high throughput, built-in JetStream for persistence
  • Kafka: For high volumes with durability guarantees (often overkill for real-time UI)
  • Ably/Pusher: Managed services that abstract away the complexity
  • Supabase Realtime: Built-in real-time layer on top of PostgreSQL

Practical example: real-time notification system

Let's build a complete notification system that's scalable and works with SSE:

// lib/notifications.ts
import { RealTimeBroker } from './broker';
import { prisma } from './prisma';

export class NotificationService {
  constructor(private broker: RealTimeBroker) {}

  async send(userId: string, notification: {
    title: string;
    body: string;
    type: 'info' | 'warning' | 'success' | 'error';
    actionUrl?: string;
  }) {
    // 1. Persist in database
    const saved = await prisma.notification.create({
      data: { userId, ...notification, read: false },
    });

    // 2. Publish via pub/sub for real-time delivery
    await this.broker.publish(`notifications:${userId}`, {
      type: 'new_notification',
      notification: saved,
    });

    return saved;
  }

  async markAsRead(notificationId: string, userId: string) {
    await prisma.notification.update({
      where: { id: notificationId, userId },
      data: { read: true },
    });

    await this.broker.publish(`notifications:${userId}`, {
      type: 'notification_read',
      notificationId,
    });
  }
}
// React hook for real-time notifications
function useNotifications() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [unreadCount, setUnreadCount] = useState(0);

  useEffect(() => {
    fetch('/api/notifications')
      .then(res => res.json())
      .then(data => {
        setNotifications(data.notifications);
        setUnreadCount(data.unreadCount);
      });

    const eventSource = new EventSource('/api/notifications/stream');

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);

      switch (data.type) {
        case 'new_notification':
          setNotifications(prev => [data.notification, ...prev]);
          setUnreadCount(prev => prev + 1);
          if (Notification.permission === 'granted') {
            new Notification(data.notification.title, {
              body: data.notification.body,
            });
          }
          break;

        case 'notification_read':
          setNotifications(prev =>
            prev.map(n =>
              n.id === data.notificationId ? { ...n, read: true } : n
            )
          );
          setUnreadCount(prev => Math.max(0, prev - 1));
          break;
      }
    };

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

  return { notifications, unreadCount };
}

Performance tips for production

1. Limit the number of connections

// Multiplexing: one connection per client, multiple channels
const eventSource = new EventSource(
  '/api/events?channels=notifications,dashboard,chat'
);

2. Throttle high-frequency updates

// Server-side throttling for cursor updates
import { throttle } from 'lodash';

const throttledBroadcast = throttle((roomId, data) => {
  broadcastToRoom(roomId, data);
}, 50); // Max 20 updates per second

3. Graceful degradation

// Fallback from WebSocket → SSE → Polling
function createConnection(url: string) {
  if ('WebSocket' in window) {
    try { return new WebSocketConnection(url); }
    catch { /* fall through */ }
  }
  if ('EventSource' in window) {
    return new SSEConnection(url);
  }
  return new PollingConnection(url);
}

4. Connection management at scale

  • Use a connection limit per user (e.g., max 5 tabs)
  • Implement backpressure: if the client doesn't consume fast enough, buffer or drop messages
  • Monitor the number of active connections per server instance
  • Consider sticky sessions or a dedicated WebSocket service

Common mistakes

  1. Using WebSockets where SSE suffices — unnecessary complexity
  2. No reconnect logic — connections drop, always
  3. Making everything real-time — not everything needs to be instant
  4. No authentication on the WebSocket connection — don't forget to validate on connect and on every message
  5. No rate limiting on incoming WebSocket messages — protect your server from overload

Conclusion

Real-time functionality is a powerful way to make your SaaS product stand out. The key is choosing the right technology for your use case:

  • Start simple with SSE for server-to-client updates
  • Use WebSockets only for bidirectional communication
  • Invest early in a pub/sub layer for scalability
  • Implement reconnect logic from day one

The technology is mature, the tooling is available, and your users expect it. Start small, measure the impact on engagement, and build from there.

Want to add real-time features to your SaaS but don't know where to start? Contact us for a no-obligation conversation about the possibilities.