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 to 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 19/20 with --enable-preview, or 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.fromPort(8080);
// Special SSE-specific server
ServerSentEventServer sseServer = 
  ServerSentEventServer.fromPort(8081);

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

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

Detailed ServerSentEventServer configuration options are available in the Server Configuration documentation.

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 for rejected handshakes (and regular requests), 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.Accepted.builder()
    .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.fromPath("/chats/123/event-source");
ServerSentEventBroadcaster broadcaster = 
  sseServer.acquireBroadcaster(resourcePath).orElseThrow();

// ...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.fromPath("/chats/123/event-source");
ServerSentEventBroadcaster broadcaster = 
  sseServer.acquireBroadcaster(resourcePath).orElseThrow();

// ...and send a comment to all listeners on /chats/123/event-source.
// The below comment will write ": testing" to the socket
ServerSentEventComment comment = ServerSentEventComment.fromComment("testing");

broadcaster.broadcastComment(comment);

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.

Client Context and Memoized Broadcasting

If you'd like to broadcast different payloads to different groups of clients (for example, grouped by Locale), you can attach a per-connection client context during the handshake and then use Soklet's memoization-aware broadcast methods. The broadcaster will memoize payloads per unique key and reuse them across connections.

This is a common requirement in production systems. Your users can have different locales, time zones, roles, etc. and you may need to perform broadcasts which take those combinations into account to efficiently fire off "tailored" broadcasts.

@ServerSentEventSource("/chats/{chatId}/event-source")
public HandshakeResult chatEventSource(
  @PathParameter Long chatId,
  Request request
) {
  // Ordered locales via Soklet's Accept-Language parsing
  List<Locale> locales = request.getLocales();
  Locale locale = locales.isEmpty()
    ? Locale.getDefault()
    : locales.get(0);

  // When accepting the handshake, store off the Locale on the connection.
  // The clientContext is of type Object, so use whatever value you like
  return HandshakeResult.Accepted.builder()
    .clientContext(locale)
    .build();
}

// Later, when broadcasting:
ServerSentEventBroadcaster broadcaster =
  sseServer.acquireBroadcaster(ResourcePath.fromPath("/chats/123/event-source")).orElseThrow();

// Group by Locale and memoize payloads per-locale
broadcaster.broadcastEvent(
  // First, turn the context into a key that represents a unique type of broadcast.
  // Here, it's just a broadcast per-Locale (e.g. US English and Brazilian Portuguese)
  // so we just return the Locale by casting our clientContext Object.
  // Real systems might specify a richer clientContext and map it to
  // a "key" type with Locale, ZoneId, user role, etc.
  (clientContext) -> (Locale) clientContext,
  // ...then, memoization guarantees we construct exactly 1 payload per-Locale
  (locale) -> ServerSentEvent.withEvent("chat-message")
    .data(exampleLocalizedMessageFor(locale))
    .build()
);

This reduces serialization overhead when many clients share the same key.

Why Is This Important?

Scalability. Suppose you have 1,000 connected clients and 999 of them have the en-US locale.

Memoization means you only build 2 payloads (one for en-US and one for the remaining locale) instead of 1,000.

References:

Automated Testing

Soklet offers first-class support for SSE integration testing. See the Testing documentation for details and example code.

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.Accepted.builder()
    .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.fromWhitelistedOrigins(
    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.fromPort(8080);
ServerSentEventServer sseServer = 
  ServerSentEventServer.fromPort(8081);

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

try (Soklet soklet = Soklet.fromConfig(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
Date: Sat, 18 Oct 2025 13:04:12 GMT
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.fromPath("/hello-world");
ServerSentEventBroadcaster broadcaster = 
  sseServer.acquireBroadcaster(resourcePath).orElseThrow();
 
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
Server Configuration