HumanOnlyWeb

🍁 writer of code. drinker of coffee. human on the web.

Building a Commenting System [Part 3]: Real-time Updates with Server-Sent Events

By HumanOnlyWeb

WebSockets are the usual choice for real-time features. But for a comments section, they felt like overkill.

Why Not WebSockets?

WebSockets let both sides talk to each other. This makes it great for chat apps, collaborative editors, multiplayer games.

But comments don't need that. Users submit comments through regular POST requests. The only real-time part is telling other users when something changes. That's one-way: server to client.

Server-Sent Events (SSE) do exactly this. One HTTP connection that stays open, server pushes data whenever it wants, client just listens.

Other benefits of SSE:

  • Auto-reconnection is built into the browser's EventSource API
  • Simpler server code
  • Easy to debug

The Event System

We need to be able to broadcast our events before we can stream them. I built a simple comment event emitter util organized by post slug:

// server/utils/comment-events.ts
import type { CommentEvent } from "~~/shared/types";

type Listener = (event: CommentEvent) => void;

// THIS DOES NOT WORK ON CLOUDFLARE WORKERS. MORE ON THIS BELOW.
const listeners = new Map<string, Set<Listener>>();

export const commentEvents = {
  subscribe(postSlug: string, listener: Listener): () => void {
    if (!listeners.has(postSlug)) {
      listeners.set(postSlug, new Set());
    }
    listeners.get(postSlug)!.add(listener);

    return () => {
      listeners.get(postSlug)?.delete(listener);
      if (listeners.get(postSlug)?.size === 0) {
        listeners.delete(postSlug);
      }
    };
  },

  emit(postSlug: string, event: CommentEvent): void {
    listeners.get(postSlug)?.forEach((fn) => {
      try {
        fn(event);
      } catch {
        // We do nothing here since we don't want one listener's error to affect others
      }
    });
  },
};

Each post gets its own set of listeners. When something happens (comment created, updated, deleted, or reaction changed), I call emit() with the post slug and event data. All connected clients watching that post receive the event.

The event types look like this:

// shared/types/index.ts
export type CommentEvent =
  | { type: "comment:created"; comment: CommentResponse }
  | { type: "comment:updated"; comment: CommentResponse }
  | { type: "comment:deleted"; commentId: string }
  | { type: "reaction:updated"; commentId: string; count: number };

TypeScript knows what shape to expect for each event type, which makes the client-side handler easier to write.

The SSE Endpoint

Thin route handler that calls the stream method on the controller:

// server/api/posts/[slug]/comments/stream.get.ts
export default defineEventHandler(async (event) => {
  try {
    const { commentController } = useContainer(event);
    return await commentController.stream(event);
  } catch (error) {
    return handleError(event, error);
  }
});

Implementation of the stream method looks like this:

// server/features/comment/comment.controller.ts
async stream(event: H3Event) {
  const { slug } = await validateRouterParams(event, slugParamsSchema);

  const eventStream = createEventStream(event);

  const unsubscribe = commentEvents.subscribe(slug, (data) => {
    void eventStream.push(JSON.stringify(data));
  });

  const heartbeat = setInterval(() => {
    void eventStream.push(": heartbeat\n\n");
  }, 30_000);

  setTimeout(() => {
    void eventStream.push(JSON.stringify({ type: "connected", postSlug: slug }));
  }, 0);

  eventStream.onClosed(() => {
    clearInterval(heartbeat);
    unsubscribe();
    void eventStream.close();
  });

  return eventStream.send();
}

Simple. Here is what is happening:

  • createEventStream(event) sets up the SSE response with the right headers
  • commentEvents.subscribe() listens for events on this post and pushes them to the client
  • The heartbeat sends a ping every 30 seconds to keep the connection alive. The : prefix makes it an SSE comment which browsers ignore
  • eventStream.onClosed() cleans up when the client disconnects

Emitting Events

When a comment is created, updated, or deleted, the controller emits an event:

// In CommentController.create()
const comment = await this.commentService.create({
  postSlug: slug,
  userId,
  content: body.content,
  parentId: body.parentId,
});

const response: CommentResponse = {
  id: comment.id,
  postSlug: comment.postSlug,
  content: comment.content,
  // ... other fields
  isOwner: true,
};

commentEvents.emit(slug, { type: "comment:created", comment: response });

return response;

The same response object is used for both the SSE event and the HTTP response. The client that created the comment handles the duplicate by checking if the comment already exists in the tree (more on that later).

For reactions, I emit just the new count. No need to send the whole comment:

// In ReactionController.toggle()
const result = await this.reactionService.toggle({ ... });

commentEvents.emit(postSlug, {
  type: "reaction:updated",
  commentId,
  count: result.count,
});

Client-Side

On the frontend, I use VueUse's useEventSource to connect:

// In useComments composable
const sseUrl = computed(() => `/api/posts/${slug.value}/comments/stream`);

const { data: sseData, status: sseStatus } = useEventSource(sseUrl, [], {
  autoReconnect: {
    retries: 3,
    delay: 1000,
  },
});

It handles reconnection automatically. Then I watch for incoming data:

watch(sseData, (raw) => {
  if (!raw) return;

  try {
    const event = JSON.parse(raw) as CommentEvent | { type: "connected" };

    if (event.type === "connected") {
      sseConnected.value = true;
      return;
    }

    switch (event.type) {
      case "comment:created": {
        // Don't add if already present (handles own comments)
        if (!findInTree(comments.value, event.comment.id)) {
          addToTree({ ...event.comment, isOwner: false });
          totalCount.value++;
        }
        break;
      }
      case "comment:updated": {
        updateInTreeById(event.comment.id, (comment) => {
          comment.content = event.comment.content;
          comment.isEdited = event.comment.isEdited;
        });
        break;
      }
      case "comment:deleted": {
        updateInTreeById(event.commentId, (comment) => {
          comment.isDeleted = true;
          comment.content = "[Comment deleted]";
        });
        break;
      }
      case "reaction:updated": {
        updateInTreeById(event.commentId, (comment) => {
          comment.reactionCount = event.count;
        });
        break;
      }
    }
  } catch {
    // Ignore parse errors (heartbeat messages aren't JSON)
  }
});

The findInTree check for comment:created matters because when I post a comment, I add it to the tree right away (optimistic update). Then the SSE event arrives with the same comment. Without this check, I'd have duplicates.

The Serverless Problem

This in-memory approach doesn't actually work on serverless platforms like Cloudflare Workers.

Traditional servers have one process handling all requests. The in-memory listeners Map is shared, so when you emit an event, everyone gets it.

Serverless is different. Each request might run on a different instance (Cloudflare calls them "isolates"). My SSE connection might be on isolate A, your POST request might hit isolate B. When you emit an event on isolate B, I don't receive it because my listener is on isolate A.

There's no guarantee that any two requests will hit the same isolate. I've tested this in production and confirmed that real-time updates don't work reliably.

That said, the comments section still works fine. Users just need to refresh the page to see new comments. Not super awesome, but not broken either.

I'm working on a fix using Cloudflare Durable Objects, where each post would have its own Durable Object managing SSE connections. That way, all requests for a given post route to the same place. More on that in a future post (hopefully).

That said, if you're deploying to a traditional server, this in-memory approach will work fine.

Debugging

SSE is easy to debug. Open DevTools, go to the Network tab, and look for the /stream request. Click on it and check the "EventStream" or "Messages" tab, you'll see every event as it arrives.

If events aren't showing up, check that the connection stays open and isn't closing immediately.

What's Next

SSE gets events to the client, but what happens when they arrive? The UI needs to update smoothly, and ideally feel instant even before the server responds.

In the next part, I'll cover the useComments composable and how it handles optimistic updates.

All Comments 0

Be the first to comment.