AboutExperienceBlogContact
LinkedInGitHubGitLabWhatsApp
All posts

3 min read

The Empty Poll

architecturesseangularspring-bootreal-time

A colleague sends you something. You should not have to refresh the page to find out.

The first version polled: every few seconds the browser asked "anything new?" and usually heard "no." Shortening the interval only multiplies empty requests, and the notification is still late by up to one interval. Polling scales the cost of asking, not the speed of knowing.

The problem is one-directional: the server knows when something happens, the client only needs to be told. That is a broadcast, not a conversation.

Server-Sent Events

SSE fits exactly: one long-lived HTTP connection the server pushes to. No polling, no WebSocket handshake for a channel that only flows one way. On Spring Boot it is an SseEmitter per user, held in a registry:

@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@AuthenticationPrincipal User user) {
  SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
  registry.add(user.id(), emitter);
  emitter.onCompletion(() -> registry.remove(user.id(), emitter));
  emitter.onTimeout(() -> registry.remove(user.id(), emitter));
  return emitter;
}

void notify(UserId recipient, Notification event) {
  for (SseEmitter emitter : registry.get(recipient)) {
    try {
      emitter.send(SseEmitter.event().name("stream").data(event));
    } catch (IOException dead) {
      registry.remove(recipient, emitter);
    }
  }
}

The custom-header trap

The stream sits behind the same auth as everything else: an Authorization header. The browser's native EventSource cannot send one.

event-source-polyfill speaks the same protocol but accepts real headers. That header was the only reason to reach for it.

Reconnection is a separate concern: both native EventSource and the polyfill reconnect on their own. Here that default retry is replaced with a capped exponential backoff:

import { EventSourcePolyfill } from "event-source-polyfill";

connect(retry = 0): void {
  const stream = new EventSourcePolyfill("/api/stream", {
    headers: { Authorization: `Bearer ${this.token}` },
  });

  stream.addEventListener("stream", (event) => {
    this.show(JSON.parse((event as MessageEvent).data));
    retry = 0;
  });

  stream.onerror = () => {
    stream.close();
    const delay = Math.min(30_000, 1_000 * 2 ** retry);
    window.setTimeout(() => this.connect(retry + 1), delay);
  };
}

That backoff is the difference between a demo and something people trust for a workday: a dropped connection recovers on its own, and nobody has to refresh.

The same idea, server-pushed updates over one long-lived connection, is what I later built into a small Kotlin library: stream-pulse, published on JitPack.

Result

Notifications arrive as the colleague acts, not one interval late. The server answers no empty polls. A dropped connection reconnects itself.

The tool was small. The engineering was everything around it.