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.

Compared to WebSockets, SSE keeps end-to-end encryption for the lifetime of the connection when you use HTTPS, offers a very small browser API surface through EventSource, gives browsers automatic reconnect behavior, and avoids the deployment friction of a custom bidirectional protocol that may be blocked by intermediaries.


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-Side Implementation

Server-side, you must do 3 things:

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

Configuration

A Soklet app that supports Server-Sent Events must be configured with an SseServer. A regular HttpServer is only required if the same app also exposes ordinary HTTP Resource Methods.

// Special SSE-specific server
SseServer sseServer = SseServer.fromPort(8081);

// Provide the SSE server to Soklet.
// If you also serve ordinary HTTP resources,
// add .httpServer(HttpServer.fromPort(8080))
SokletConfig config = SokletConfig.withSseServer(sseServer).build();

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

ShutdownTrigger.ENTER_KEY is convenient for local demos, but it only works when standard input is attached to an interactive TTY, and it applies process-wide across all Soklet instances in the JVM. For containers, services, and CI, prefer plain Soklet::awaitShutdown and let normal JVM shutdown hooks or OS signals stop the process.

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

Heads Up!

HttpServer and SseServer 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 for SSE handshakes, your ResponseMarshaler still handles writing response data for rejected handshakes (and regular requests, if you also configured an HTTP server), 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:

Aside from the SSE-specific annotation and return type, parameter binding works the same way as for ordinary Resource Methods. @PathParameter, @QueryParameter, @RequestHeader, and @RequestCookie values still flow through Soklet's ValueConverterRegistry, so typed handshake inputs like Long chatId, Optional<UUID> tenantId, or Locale locale work as expected.

Here's a minimal implementation:

public class ChatResource {
  // Use @SseEventSource, not @GET.
  // Return type must be declared as SseHandshakeResult
  @SseEventSource("/chats/{chatId}/event-source")
  public SseHandshakeResult chatEventSource(@PathParameter Long chatId) {
    // Accept the client handshake.
    // Soklet can now broadcast server-sent events to this client
    return SseHandshakeResult.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.

@SseEventSource("/chats/{chatId}/event-source")
public SseHandshakeResult 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" SseHandshakeResult builder
  return SseHandshakeResult.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:

@SseEventSource("/chats/{chatId}/event-source")
public SseHandshakeResult 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 SseHandshakeResult.rejectWithResponse(response);
}

Implicit Rejection

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

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

  return SseHandshakeResult.accept();
}

References:

Event Broadcasting

You may broadcast SseEvent instances to clients by acquiring a handle to a SseBroadcaster. 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...
SseServer sseServer = ...;

// ...acquire a broadcaster for a specific resource path...
ResourcePath resourcePath =
  ResourcePath.fromPath("/chats/123/event-source");
SseBroadcaster broadcaster =
  sseServer.acquireBroadcaster(resourcePath).orElseThrow();

// ...construct the payload...
SseEvent event = SseEvent.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 SseBroadcaster::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");
SseBroadcaster 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
SseComment comment = SseComment.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.

@SseEventSource("/chats/{chatId}/event-source")
public SseHandshakeResult 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 SseHandshakeResult.Accepted.builder()
    .clientContext(locale)
    .build();
}

// Later, when broadcasting:
SseBroadcaster 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) -> SseEvent.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:

Clustered Deployments

SseBroadcaster instances are local to the current Soklet node. A broadcast only reaches clients whose SSE connections are currently attached to that node.

For a clustered deployment, the usual pattern is:

  1. Persist the business event or enqueue it to a shared queue or pub/sub system.
  2. Have each Soklet node consume that shared event.
  3. On each node, acquire the appropriate local SseBroadcaster and perform the broadcast there.

This is usually a better fit than trying to route all clients for a given SSE resource path to a single node. In practice, distributed SSE systems commonly use Redis pub/sub, Kafka, SQS plus consumers, or similar shared infrastructure to fan events out across the cluster.

If you support reconnect catch-up with Last-Event-ID, the replay data should also come from a shared durable store or log, not from memory local to a single Soklet node.

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 SseUnicaster is provided to the initializer, which allows you to send any SSE payloads you like to just this client.

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

@SseEventSource("/chats/{chatId}/event-source")
public SseHandshakeResult 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 SseHandshakeResult.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 -> SseEvent.withEvent("chat-message")
          .id(catchupMessage.id())
          .data(catchupMessage.toJson())
          .retry(Duration.ofSeconds(5))
          .build())
        .forEach(event -> unicaster.unicastEvent(event));
    })
    .build();
}

Heads Up!

Just like SseBroadcaster instances, you should not hold a long-lived reference to the SseUnicaster 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.withSseServer(...)
  .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:

@SseEventSource("/chats/{chatId}/event-source")
public SseHandshakeResult 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 SseHandshakeResult.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...

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

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

SseServer sseServer =
  SseServer.fromPort(8081);

SokletConfig config = SokletConfig.withSseServer(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");
SseBroadcaster broadcaster =
  sseServer.acquireBroadcaster(resourcePath).orElseThrow();

SseEvent event = SseEvent.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