Soklet Logo

Core Concepts

Server-Sent Events

Server-Sent Events (or SSE) is a technology that enables efficient data "pushes" to clients over a regular HTTP or HTTPS connection. Conceptually, a web browser tells a server that it's interested in receiving events from a particular URL. A TCP socket that speaks HTTP is established between the two and held open indefinitely. The server writes text/event-stream-formatted data to the socket as needed, and the client listens for data events using standard JavaScript functionality available in all major browsers.

SSE is appropriate for many scenarios that were traditionally solved by client polling. For example, after a record is updated in your database, you might want to securely send a "record changed" notification anyone who is viewing the record in their web browser - the webpage can then refresh itself and display the updated data. SSE is also a great fit for AI-backed applications that stream LLM responses while they reason.

Soklet offers SSE support for platforms that support Virtual Threads (JDK 21+).

Why Not WebSockets?

SSE technology is similar to WebSockets, which Soklet does not support. After an initial handshake over HTTP, WebSockets switch to a special protocol on top of TCP for high-performance bidirectional communication. This design is well-suited for systems like games which require low-latency interactions between two parties, but comes with complexity costs that might not be worth paying for a traditional CRUD application.

SSE has the following advantages over WebSockets:

  • End-to-end encryption for the lifetime of the connection (assuming your system uses HTTPS), as opposed to only during the initial handshake
  • The EventSource JavaScript programming model has a small surface area and is easy for clients to implement
  • Browsers will automatically attempt to re-establish SSE connections if they are broken (e.g. temporary loss of network connectivity), adding a layer of robustness "for free"
  • WebSockets are sometimes blocked by security software or corporate proxies. SSE is "just another HTTP connection" so it sidesteps these restrictions

Client Implementation

Let's look at how SSE works from the client's perspective.

The code below is vanilla JavaScript and will run in any modern web browser. No dependencies are required.

First, an EventSource is acquired:

// We want to listen to events for a specific chat...
const EVENT_SOURCE_URL = 'https://sse.example.com/chats/123/event-source';

// ...so we register an EventSource for it.
let eventSource = new EventSource(EVENT_SOURCE_URL);

Then, event listeners are added:

// When the connection is opened
eventSource.addEventListener('open', (e) => {
  console.log(`EventSource connection opened for ${EVENT_SOURCE_URL}`);
});

// When the connection encounters an error
eventSource.addEventListener('error', (e) => {
  console.log(`Error for ${EVENT_SOURCE_URL}`, e);
});

// SSE connection streams can include named events.
// Here, we listen specifically for those named "chat-message"
eventSource.addEventListener('chat-message', (e) => {
  console.log(`Chat message received: ${e.data}`);
});

When you're done with your EventSource, shut it down:

eventSource.close();

That's it - the standard JavaScript SSE API has a tiny footprint and almost no learning curve.

Server Implementation

Server-side, you must do 3 things:

  1. Configure a ServerSentEventServer
  2. Define one or more Event Source Methods to "handshake" with clients
  3. (sometime later) Broadcast events to clients

Configuration

All Soklet apps must be configured with a Server. Apps that support Server-Sent Events must also be configured with a ServerSentEventServer.

// Your normal HTTP server
Server server = Server.withPort(8080).build();
// Special SSE-specific server
ServerSentEventServer sseServer = 
  ServerSentEventServer.withPort(8081).build();

// Provide both servers to Soklet
SokletConfig config = SokletConfig.withServer(server)
  .serverSentEventServer(sseServer)
  .build();

// Soklet starts and manages both servers
try (Soklet soklet = Soklet.withConfig(config)) {
  soklet.start();
  System.out.println("Soklet started, press [enter] to exit");
  soklet.awaitShutdown(ShutdownTrigger.ENTER_KEY);
}

Heads Up!

Server and ServerSentEventServer are distinct and cannot share the same port.

In production environments, you might have your REST API resolvable at api.example.com and your SSE API resolvable at sse.example.com.

Soklet's SSE support is designed to work seamlessly with your existing setup - your configured CorsAuthorizer still applies cross-domain security rules, your ResponseMarshaler still handles writing response data, your InstanceProvider and your Value Converters still work as you would expect, and so on.

References:

Event Source Methods

Event Source Methods are how SSE clients connect and "handshake" with your server. They look and code like regular Resource Methods, but have the following special characteristics:

Here's a minimal implementation:

public class ChatResource {
  // Use @ServerSentEventSource, not @GET.
  // Return type must be declared as HandshakeResult
  @ServerSentEventSource("/chats/{chatId}/event-source")
  public HandshakeResult chatEventSource(@PathParameter Long chatId) {
    // Accept the client handshake.
    // Soklet can now broadcast server-sent events to this client
    return HandshakeResult.accept();   
  }
}

Accepting Handshakes

In the event of an accepted handshake, Soklet will write an HTTP 200 OK response to the client, along with a set of default headers - but no response body. The socket is then held open to support subsequent Event Broadcasting.

Here are the default headers:

  • Content-Type: text/event-stream; charset=UTF-8
  • Cache-Control: no-cache, no-transform
  • Connection: keep-alive
  • X-Accel-Buffering: no

When accepting a handshake, you may optionally specify your own headers and cookies.

@ServerSentEventSource("/chats/{chatId}/event-source")
public HandshakeResult chatEventSource(@PathParameter Long chatId) {
  // Declare some response headers
  Map<String, Set<String>> headers = Map.of(
    "X-Powered-By", Set.of("Soklet"),
    // Here we override a default header
    "X-Accel-Buffering", Set.of("yes")
  );

  // Declare a response cookie
  Set<ResponseCookie> cookies = Set.of(
    ResponseCookie.withName("lastRequest")
      .value(Instant.now().toString())
      .httpOnly(true)
      .secure(true)
      .maxAge(Duration.ofMinutes(5))
      .sameSite(SameSite.LAX)
      .build()
  );  

  // Supply to the "accepted" HandshakeResult builder
  return HandshakeResult.acceptWithDefaults()
    .headers(headers)
    .cookies(cookies)
    .build();
}

Post-Handshake Client Initialization

Soklet also provides functionality to "prime" the client via unicast immediately after a successful handshake (and before any other thread can broadcast SSE payloads to it). This is commonly done to "catch up" previously-disconnected clients when they provide a Last-Event-ID request header, which modern browsers do automatically. An example of this is provided in Client Initialization below.

References:

Rejecting Handshakes

Handshakes are rejected in 2 scenarios:

  1. You explicitly reject the handshake
  2. An exception bubbles out of your Event Source Method (or occurs during upstream/downstream request processing)

A rejected handshake writes a response - which might or might not include a response body - using the normal Soklet Response Marshaling workflow. Unlike accepted handshakes, the connection is closed immediately after writing the response.

Explicit Rejection

Here's how you can explicitly reject a handshake:

@ServerSentEventSource("/chats/{chatId}/event-source")
public HandshakeResult chatEventSource(@PathParameter Long chatId) {
  // Can specify whatever status/headers/cookies/body you like
  Response response = Response.withStatusCode(403)
    .body("You're not authorized")
    .build();

  // Goes through ResponseMarshaler::forResourceMethod, as normal
  return HandshakeResult.rejectWithResponse(response);
}

Implicit Rejection

You may also implicitly reject a handshake by throwing an exception:

@ServerSentEventSource("/chats/{chatId}/event-source")
public HandshakeResult chatEventSource(@PathParameter Long chatId) {
  // Goes through ResponseMarshaler::forThrowable, as normal
  if(!userIsAuthorized(chatId))
    throw new ExampleException("You're not authorized");

  return HandshakeResult.accept();
}

References:

Event Broadcasting

You may broadcast ServerSentEvent instances to clients by acquiring a handle to a ServerSentEventBroadcaster. Any thread can do this at any time. For example, your service layer might choose to fire off a broadcast after it persists a chat message, so anyone who is "listening" to the chat will see the update almost immediately.

Broadcasters are tied to concrete resource paths like /chats/123/event-source - not resource path declarations like /chats/{chatId}/event-source. Conceptually you are expressing: "I want to push SSE data to anyone who's listening to this particular URL." Every client who had a successful handshake with that URL and is still reachable will receive the broadcast.

// Get a reference to your SSE server...
ServerSentEventServer sseServer = ...;

// ...acquire a broadcaster for a specific resource path...
ResourcePath resourcePath =
  ResourcePath.withPath("/chats/123/event-source");
ServerSentEventBroadcaster broadcaster = 
  sseServer.acquireBroadcaster(resourcePath).get();

// ...construct the payload...
ServerSentEvent event = ServerSentEvent.withEvent("chat-message")
  .id(Instant.now().toString()) // any string you like
  .data("Hello, world") // often JSON
  .retry(Duration.ofSeconds(5))
  .build();

// ...and send it to all connected clients.
broadcaster.broadcastEvent(event);

There is no need to invoke ServerSentEventBroadcaster::broadcastEvent on a separate thread - you can assume the implementation will enqueue the event and return "immediately". Pushing data to clients over the wire will happen later on separate threads of execution.

It's also possible to broadcast SSE "comment" payloads, which are distinct from true Server-Sent Events:

// Acquire broadcaster as usual...
ResourcePath resourcePath =
  ResourcePath.withPath("/chats/123/event-source");
ServerSentEventBroadcaster broadcaster = 
  sseServer.acquireBroadcaster(resourcePath).get();

// ...and send a comment to all listeners on /chats/123/event-source.
// The below string will write ": testing" to the socket
// The empty string will write ":" to the socket
broadcaster.broadcastComment("testing");

It's unlikely you will need to broadcast comments in most applications unless you have special debugging or keepalive needs.

Soklet will automatically send periodic comment "heartbeats" on your behalf to keep the socket open and quickly detect and discard broken connections.

References:

Implementation Patterns

Client Initialization

There are scenarios in which it's useful to unicast SSE data to a client immediately after a successful handshake. For example:

  • You might want to push current state to the client right away
  • You might want to send an initial message for diagnostic or debugging purposes
  • The client temporarily lost network connectivity and now needs to "catch up" on missed messages via Last-Event-ID

Let's examine the "catch up" scenario below. Browser EventSource instances will keep track of the most-recent SSE id field they've seen, and in the event of a connection drop (network outage, laptop goes to sleep, etc.) and subsequent reconnect, the Last-Event-ID header will be set to the value of the most-recently-seen id.

It's the job of your server-side code to look for this header and provide whatever data the client needs to reconstitute itself.

Soklet provides the concept of a Client Initializer - a function run immediately after the SSE handshake is accepted but before any messages can be broadcast to the client. A ServerSentEventUnicaster is provided to the initializer, which allows you to send any SSE payloads you like to just this client.

Because Soklet guarantees the ServerSentEventUnicaster will send its payloads to the client before any ServerSentEventBroadcaster has an opportunity to do so, clients can safely "catch up" without worrying about concurrently-sent messages interleaved in the event stream.

@ServerSentEventSource("/chats/{chatId}/event-source")
public HandshakeResult chatEventSource(
  @PathParameter Long chatId,
  // Browsers will send this header automatically on reconnects
  @RequestHeader(name="Last-Event-ID", optional=true) String lastEventId
) {
  Chat chat = myChatService.find(chatId);

  // Exceptions that bubble out will reject the handshake and go through the
  // ResponseMarshaler::forThrowable path, same as non-SSE Resource Methods
  if (chat == null)
    throw new NoSuchChatException();

  // If a Last-Event-ID header was sent, pull data to "catch up" the client
  List<ChatMessage> catchupMessages = new ArrayList<>();

  if(lastEventId != null)
    catchupMessages.addAll(myChatService.findCatchups(chatId, lastEventId));
  
  // Customize "accept" handshake with a client initializer
  return HandshakeResult.acceptWithDefaults()
    .clientInitializer((unicaster) -> {
      // Unicast "catchup" initialization events to this specific client.
      // The unicaster is guaranteed to write these events before any
      // other broadcaster does, allowing clients to safely catch up
      // without the risk of event interleaving
      catchupMessages.stream()
        .map(catchupMessage -> ServerSentEvent.withEvent("chat-message")
          .id(catchupMessage.id())
          .data(catchupMessage.toJson())
          .retry(Duration.ofSeconds(5))
          .build())
        .forEach(event -> unicaster.unicastEvent(event));
    })
    .build();
}

Heads Up!

Just like ServerSentEventBroadcaster instances, you should not hold a long-lived reference to the ServerSentEventUnicaster provided by the Client Initializer.

These instances are designed to be transient, and holding references for an extended period of time can affect Soklet's ability to perform efficient bookkeeping.

References:

Signing Tokens

SSE handshakes in browsers are limited by design: the HTTP method is always GET, you cannot supply custom headers to a native EventSource, and cookies cannot travel cross-origin by default (a common SSE scenario).

One approach is to specify withCredentials so your cookies (which might include authentication information) can travel cross-origin. However, this is not recommended because it increases CSRF attack surface area.

const EVENT_SOURCE_URL = 'https://sse.example.com/chats/123/event-source';

// URL and "withCredentials" are the only possible customizations
let eventSource = new EventSource(EVENT_SOURCE_URL, {
  withCredentials: true
});

CORS and Event Source Credentials

If you are comfortable with your CSRF posture and choose to initialize your EventSource using withCredentials: true and are connecting to a cross-origin SSE Event Source Method, you will need to make sure your CORS configuration writes Access-Control-Allow-Credentials=true for the appropriate origin. For example:

Set<String> allowedOrigins = Set.of("https://www.revetware.com");

// Custom "Access-Control-Allow-Credentials=true" decider
Function<String, Boolean> allowCredentialsResolver = (origin) -> {
  return true;
};

SokletConfig config = SokletConfig.withServer(...)
  .serverSentEventServer(...)
  .corsAuthorizer(CorsAuthorizer.withWhitelistedOrigins(
    allowedOrigins, allowCredentialsResolver
  ))
  .build();

See the CORS documentation for details.

We have established the above is not an ideal approach. So, how can you securely send credentials from the browser to Soklet?

A reliable and secure solution is to have your backend vend a cryptographically-signed, time-limited token like a JWT which is included as a query parameter in the EventSource URL.

This way, even if an attacker were to intercept request logs that include the full URL, she would not be able to "replay" authentication for the Event Source Method.

Here's how your JS code running in the browser might look:

// Make a call to your backend to get a signing token...
let signingToken = await obtainSigningToken();

// ...construct a URL with it...
let eventSourceUrl = 'https://sse.example.com/chats/123/event-source'
  + `?signingToken=${encodeURIComponent(signingToken)}`;

// ...and use the constructed URL to create the EventSource.
// Notice that we omit the less-secure "withCredentials: true" config
let eventSource = new EventSource(eventSourceUrl);

Here's how your Event Source Method might look:

@ServerSentEventSource("/chats/{chatId}/event-source")
public HandshakeResult chatEventSource(
  @PathParameter Long chatId,
  // Require that clients specify a signing token
  @QueryParameter String signingToken
) {
  Chat chat = myChatService.find(chatId);

  // Exceptions that bubble out will reject the handshake and go through the
  // ResponseMarshaler.forThrowable(...) path, same as non-SSE Resource Methods
  if (chat == null)
    throw new ChatNotFoundException();

  // Perform cryptographic + expiry verification of the token here.
  // You might also mark it as "cannot be reused" in your database
  myAuthorizationService.verify(signingToken);

  // Accept the handshake with no additional data
  return HandshakeResult.accept();   
}

Addendum: Using netcat

To experiment with SSE functionality, you can create a simple webpage and use the Javascript API as outlined in Client Implementation, or use netcat on the command line.

First, ensure you have an Event Source Method declared...

@ServerSentEventSource("/hello-world")
public HandshakeResult helloWorld() {
  // Immediately accept, no special behavior
  return HandshakeResult.accept();   
}

...and then spin up your SSE server on 8081.

Server server = Server.withPort(8080).build();
ServerSentEventServer sseServer = 
  ServerSentEventServer.withPort(8081).build();

SokletConfig config = SokletConfig.withServer(server)
  .serverSentEventServer(sseServer)
  .build();

try (Soklet soklet = Soklet.withConfig(config)) {
  soklet.start();
  System.out.println("Soklet started, press [enter] to exit");
  soklet.awaitShutdown(ShutdownTrigger.ENTER_KEY);
}

Now, fire up netcat and pipe a raw HTTP request for GET /hello-world to it:

# Hold a socket open for GET /hello-world and wait for server-sent events.
# This is a bare-bones raw HTTP request: a method, a URL, and a single `Host` header.
echo -ne 'GET /hello-world HTTP/1.1\r\nHost: localhost\r\n\r\n' | netcat localhost 8081

Your Event Source Method will accept the initial handshake and immediately respond with SSE headers:

HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=UTF-8
Cache-Control: no-cache
Cache-Control: no-transform
Connection: keep-alive
X-Accel-Buffering: no

The socket is kept open until you press Ctrl+C. Soklet will periodically write "heartbeat" messages which look like this:

:

If you fire off a broadcast...

ResourcePath resourcePath = ResourcePath.withPath("/hello-world");
ServerSentEventBroadcaster broadcaster = 
  sseServer.acquireBroadcaster(resourcePath).get();
 
ServerSentEvent event = ServerSentEvent.withEvent("hello")
  .data("testing\nmultiple\nlines")
  .id(Instant.now().toString())
  .retry(Duration.ofSeconds(5))
  .build();

broadcaster.broadcastEvent(event);

...you'll see its payload written to the socket:

event: hello
id: 2025-10-18T13:04:46.750110Z
retry: 5000
data: testing
data: multiple
data: lines
Previous
Servlet Integration