In this article, we’ll learn how to send messages to a client using Server-Sent Events (SSE) initiated by a client’s persistent HTTP POST connection.
1. What is Server-Sent Events (SSE)?
Server-Sent Events (SSE) is a web push technology, based on the HTTP protocol, that allows clients to subscribe to a stream of events, pushed by the servers over a persistent HTTP connection. The server sends chunks of data (as data event streams) asynchronously to the client, once the client connection is established.
How it works
- Client initiates: The client establishes and opens a connection to a specific server endpoint using the
Accept: text/event-streamHTTP header. - Server keeps connection open: The server responds with a
Content-Type: text/event-streamheader and keeps the connection alive (setting theConnection: keep-aliveheader). - Server pushes data: Whenever new data is available, the server sends it as text messages, called events (ending with a blank line) directly to the client, via a long-running HTTP connection.
- Client receives: The client automatically receives these messages sent back by the server as events, and processes the messages without closing the connection. In case of lost connection, clients can continue retrieving the previous streams of events by setting a
Last-Event-IDHTTP header in a new request.
So, SSE is a one-way (unidirectional) data flow from the server to the client, and the server reuses the same connection to publish new messages to the client when its available.
SSE stream has text/event-stream media type and contains multiple SSE events. SSE event is a data structure encoded with UTF-8 and contains fields and comments. The field can be event, data, id, retry, or comment. Any other fields will be ignored.
The SSE protocol diagram
Here is a sequence diagram that visually shows the SSE protocol flow between client and server.
The SSE message structure
The SSE message consists of a series of name/value pairs, each name/value pair is separated by a <EOL>, while each SSE message sent is separated by <EOL><EOL> (where <EOL> = CRLF or CR or LF).
A single example of an SSE message typically looks as follows:
: An example comment for the SSE stream
id: 1
event: update
data: {"message": "Hello, world!"}
retry: 5000
(Note: A required blank line is appended at the end of the event message at the end in order to terminate the event).
Key Fields in an SSE Message
data:(Mandatory) The payload of the message. If a message has multipledata:lines, they are concatenated with a newline character between them.event:(Optional) The type of event. This allows clients to register specific event listeners (e.g.,event: user_login). If this event type is not provided, it defaults to “message”.id:(Optional) A unique identifier for the event, used to track the message sequence. If the connection drops, the client sends this ID back to the server in theLast-Event-IDheader to resume the stream.retry:(Optional) The reconnection time in milliseconds. It tells the browser how long to wait before trying to reconnect if the connection is lost.:(Comment) Lines starting with a colon are ignored and often used as a heartbeat to keep the connection alive.
Now, let us investigate the possible ways of building an SSE server in Jakarta REST.
2. Server-Sent Events API in Jakarta REST
Jakarta REST provides built-in APIs to handle Server-Sent Events (SSE) within the jakarta.ws.rs.sse package. It is used to accept a connection from the client and send events to one or more clients.
2.1 Current Server-side SSE implementation.
To create an SSE server, a resource method must be annotated with a @GET annotation, produce the media type as MediaType.SERVER_SENT_EVENTS (or "text/event-stream") and inject an SseEventSink to the resource method parameter. When all 3 conditions are met, the resource method is an SSE resource method.
The following example accepts SSE connections and sends a new SSE event immediately to the client, via the OutboundSseEvent object, before closing the connection.
@Path("events")
public class SseResource {
@GET
@Produces(MediaType.SERVER_SENT_EVENTS)
public void listenToEvents(@Context SseEventSink sink, @Context Sse sse) {
// Example of sending an event immediately (usually done in a background task)
OutboundSseEvent event = sse.newEvent("My first event data");
sink.send(event);
}
}
The key interfaces for Server-side implementation:
- The
SseEventSinkinstance, injected into the resource method, represents the incoming connection from the client and is used to send the events. - The
Sseinstance is injected to serve as a factory for creatingOutboundSseEventinstances and anSseBroadcaster(which we will view later). - The
OutboundSseEventinstance represents a single outbound Server-Sent Event (SSE) message being sent from the server to a client. You can obtain an object via one of theSsefactory methodnewEvent(...), or via its Builder retrieved by theSse.newEventBuilder()method. - The application may need to send SSE event messages to multiple clients simultaneously. In Jakarta REST, multiple
SseEventSink‘s can be registered to a singleSseBroadcaster, which can be obtained from the injectedSse‘snewBroadcaster()method. TheOutboundSseEventinstance can, then, be broadcasted to all event sinks by calling theSseBroadcaster.broadcast()method.
The following example accepts an SSE connection as an SseEventSink, registers it to an SseBroadcaster and broadcasts the SSE event to all registered clients.
@Path("events")
@ApplicationScoped
public class SseResource {
@Context
private Sse sse;
private volatile SseBroadcaster sseBroadcaster;
@PostConstruct
public init() {
this.sseBroadcaster = sse.newBroadcaster();
}
@GET
@Produces(MediaType.SERVER_SENT_EVENTS)
public void listenToEvents(@Context SseEventSink sink) {
sseBroadcaster.register(sink);
OutboundSseEvent event = sse.newEvent("My first event data");
sseBroadcaster.broadcast(event); //Broadcast to every client registered.
}
}
By now, you’ll realise that an SseEventSink is injected only on @GET SSE resource method. But, what if your APIs require you to handle client requests and respond with SSE events only on HTTP POST calls?
In Jakarta REST, replacing the @GET to a @POST on an SSE resource method will result in the container injecting a null when injecting an SseEventSink.
Consuming SSE streams using the Jakarta REST Client API
Reading Server-Sent Events (SSE) using the Jakarta REST client API is accomplished using the SseEventSource interface. This interface is to simulate the JavaScript EventSource object. This client-side component allows you to connect to an SSE endpoint and register consumers to handle incoming events asynchronously.
Steps to Read SSE Events
The following steps outline how to set up and use the SseEventSource:
- Obtain a
ClientInstance: Start by creating a standardClientinstance usingClientBuilder. - Define the Target URL: Configure a
WebTargetfor the SSE endpoint. - Build the
SseEventSource: Use theSseEventSource.target(target).build()factory method to create an event source instance. You can also customise the reconnect delay if needed usingreconnectingEvery(). - Register Event Consumers: Register consumers (lambda expressions or method references) to process different aspects of the event stream:
onEvent: AConsumer<InboundSseEvent>invoked for each received event.onError: AConsumer<Throwable>invoked if an unrecoverable error occurs.onComplete: ARunnableinvoked when the stream naturally completes (e.g., server sends a 204 No Content status).
- Open the Connection: Start the connection to the server and begin processing incoming events using
open(). TheSseEventSourceruns in its own internal thread and handles automatic reconnections by default. - Manage the Lifecycle: The
SseEventSourceimplementsAutoCloseable, so it is best practice to use it within atry-with-resourcesblock to ensure proper closure, or explicitly calleventSource.close()when you are finished receiving events.
The following example showcases the consummation of the SSE event InboundSseEvent message via the SseEventSource using the Jakarta REST client API.
public class SSEConsumer {
public static void main(String... args) {
Client client = ClientBuilder.newClient();
WebTarget target = client.target("http://localhost:8080/yourapp/events");
try (SseEventSource eventSource = SseEventSource.target(target).build()) {
Consumer<InboundSseEvent> onEvent = event -> {
// Read the event data and process it
System.out.println("Received event: " + event.readData(String.class));
};
eventSource.register(onEvent,
error -> error.printStackTrace(),
() -> System.out.println("Connection closed"));
eventSource.open();
} //The SseEventSource implements AutoCloseable, so it is best practice to use it within a try-with-resources block to ensure proper closure.
}
}
Note that the SseEventSource will connect and open to the HTTP SSE server via an HTTP GET connection.
Key Interfaces
SseEventSource: The primary client-side entry point for consuming SSE streams, managing the connection and event handling.InboundSseEvent: Represents a single incoming event from the server. It provides methods likereadData()to access the event’s data payload and metadata like event ID, name, or retry interval.ClientandWebTarget: Standard Jakarta REST client API components used to configure the base connection details.
But, what if your API provides an HTTP POST SSE stream response? How do we provide a POST SSE event resource call and send event messages if the SseEventSink is not available during the POST?
2.2 Server-Side HTTP POST SSE implementation.
The SseEventSink.send() and the SseBroadcaster.broadcast() both return a CompletionStage as a way to provide a handle to the action of asynchronously sending a message to a client. Thus, an SSE resource method is an asynchronous method, so future SSE events can be sent even long after the method has been returned.
We need to apply the same procedure to implement an HTTP POST SSE resource method. Luckily, Jakarta REST provides other ways to provide asynchronous processing of resources.
The jakarta.ws.rs.context.AsyncResponse interface.
An AsyncResponse, when injected to a resource method, provides a means for asynchronous response processing. The resource method returns but the response is not readily available, until it is processed further. Thus, the server keeps the client HTTP connection long-running and active until it receives the response from the server.
Key Concepts:
- Non-Blocking Operations: When a resource method is injected with
AsyncResponseusing the@Suspendedannotation, the standard server thread is immediately returned to the container’s thread pool to handle new incoming requests. - Decoupled Processing: The actual, time-consuming work (e.g., complex computations, database queries, external API calls) is executed on a separate thread (which you manage, for example, using a
ManagedExecutorServicein a Jakarta EE environment or a customExecutorService). - Resuming the Request: Once the long-running operation is complete, the application calls one of the
resume()methods on theAsyncResponseinstance. This action sends the final HTTP response back to the client and terminates the request.
The following example showcases an HTTP POST SSE event that sends an SSE data attribute to the client, and the request terminates.
@Path("events")
@ApplicationScoped
public class SseResource {
@POST
@Produces(MediaType.SERVER_SENT_EVENTS)
public void listenToEvents(@Suspended AsyncResponse asyncResponse) {
executor.submit(() -> {
//Long running operation goes here....
//Resume
asyncResponse.resume("data: Hello world!\n\n");
});
}
}
The AsyncResponse.resume() method resumes the suspended AsyncResponse instance. The object passed to the resume() method is the same object you would have returned if the resource method was a synchronous call (it can be an entity object, a primitive type or Jakarta REST Response object).
But what if you have a large data set that you want to stream as SSE data events without loading the entire data set into memory?
The jakarta.ws.rs.core.StreamingOutput interface.
The StreamingOutput interface is a type that is used as a return value from a resource method, or as an entity of a Response when you want to stream the output. It’s an alternative to MessageBodyWriter.
The advantage of StreamingOutput is that it defines a single method: void write(OutputStream output) throws IOException, WebApplicationException, which allows an application to write directly to an OutputStream as a message body, which is then sent to the client.
When a resource method returns a StreamingOutput instance (usually wrapped in a Response object), the Jakarta REST runtime calls the write method, providing the actual HTTP response output stream. Your implementation is responsible for writing all data to this stream and flushing it.
For SSE, we can write the SSE event data using the StreamingOutput and feed the instance directly into the AsyncResponse directly. That way, we still manage to write a lightweight payload to the message body that is sent to the client, as SSE event streams, even if we do have a large data set to process.
Putting it all together (the implementation).
To create an SSE POST server, a resource method must be annotated with a @POST annotation, produce the media type as MediaType.SERVER_SENT_EVENTS (or "text/event-stream") and inject an suspended AsyncResponse to the resource method parameter. The resource method, typically, returns a void.
Since we cannot use the OutboundSseEvent instance to send it as an SSE message, we will have to write the SSE event stream directly to the writer, according to the SSE stream format specification (see link of the SSE stream in the resources section).
The example below showcases the use of StreamingOutput to process large datasets, and returns as a Response entity, which is then resumed as a response by AsyncResponse.
@Path("events")
@ApplicationScoped
public class SseResource {
@POST
@Path("stream")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.SERVER_SENT_EVENTS)
public void sendMessageStream(@Suspended AsyncResponse asyncResponse) {
StreamingOutput stream = new StreamingOutput() {
@Override
public void write(OutputStream out) throws IOException, WebApplicationException {
Writer writer = new BufferedWriter(new OutputStreamWriter(out));
// Simulate writing a large dataset
for (int i = 0; i < 1000000; i++) {
writer.write("data: {id: " + i + "}\n\n");
}
writer.flush(); // Crucial to flush the stream to ensure data is sent
}
};
ResponseBuilder responseBuilder = Response.ok()
.type(MediaType.SERVER_SENT_EVENTS_TYPE)
.header("Cache-Control", "no-cache, no-transform")
.header("Connection", "keep-alive")
.entity(stream);
asyncResponse.resume(responseBuilder.build());
}
}
Note: Please ensure that you flush the Writer in order for the data to be sent.
Note: This is an oversimplification of a solution case. It’s up to the developer to ensure that the operations are safely executed in an asynchronous model, but the gist of the solution is provided for better understanding.
2.3 [Alternative option] Using the jakarta.servlet.AsyncContext interface.
A final approach in publishing SSE data to the client is relying on old-daddy faithful, the HttpServletRequest. Since Servlet 3.0, the API provides a mechanism to detach from the synchronous request/response context and send the response later via asynchronous processing, provided the client connection is still alive. The AsyncContext interface represents the execution context for an asynchronous operation that was initiated on a ServletRequest.
To obtain the AsyncContext, one can start asynchronous processing by calling the request.startAsync(), where request is an instance of ServletRequest.
In Jakarta REST, one can inject the HttpServletRequest and we can simply use the AsyncContext in the same manner as one would in a Servlet environment.
This example shows how an HttpServletRequest is injected in order to establish an asynchronous processing context to be used to send SSE events to the response, using the AsyncContext instance.
@Path("events")
@ApplicationScoped
public class SseResource {
@POST
@Path("stream")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.SERVER_SENT_EVENTS)
public void sendMessageStream(@Context HttpServletRequest request) {
AsyncContext asyncContext = request.startAsync();
asyncContext.setTimeout(0); //Infinite timeout
HttpServletResponse response = (HttpServletResponse) asyncContext.getResponse();
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
PrintWriter writer = response.getWriter();
for (int i = 0; i < 1000000; i++) {
writer.write("data: {id: " + i + "}\n\n");
}
asyncContext.complete(); //Completes the asynchronous operation that was started on the request that was used to initialize this AsyncContext, closing the response that was used to initialize this AsyncContext.
}
}
Here’s a conceptual breakdown:
- Resource Setup: Create a RESTful resource method to handle the initial POST request, which will set up the SSE stream.
- Start Async: Inside the POST method, call
request.startAsync()to get anAsyncContext, freeing the request thread. - Event Listener: Register an
AsyncListenerto handle events like timeout or completion. - Background Processing: Use an
ExecutorService(e.g., from CDI orCompletableFuture) to process the POST data (e.g., save to DB) off the main thread. - Complete/Dispatch: Call
asyncContext.complete()when done, orasyncContext.dispatch()to forward to another resource, potentially for cleanup or further processing.
3. Consuming HTTP POST SSE streams using a modern Java JDK HttpClient.
To read a POST Server-Sent Event (SSE) stream using the modern JDK HttpClient, you can send the request with a BodyHandler.ofLines() and then process the resulting Stream<String> as events.
The JDK HttpClient does not have built-in, first-class support for the full SSE protocol (e.g., automatic reconnection or event type parsing), but you can implement a basic client using its streaming capabilities.
Implementation steps:
- Create an
HttpClientinstance. - Build an
HttpRequestfor the POST method, including the body and theAccept: text/event-streamheader. - Send the request using
BodyHandlers.ofLines()to get aStream<String>which can then be processed line by line. - Process the lines to extract the SSE data.
Here is an example of how to create a basic, synchronous SSE client for a POST request:
public class SsePostClient {
public static void main(String[] args) {
// Define the target URL and the POST body
String url = "http://localhost:5001/api/extra/generate/stream"; // Replace with your SSE endpoint
String requestBody = "{\"someKey\": \"someValue\"}"; // Replace with your POST data
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "text/event-stream") // Standard SSE MIME type
.header("Content-Type", "application/json") // Adjust content type as needed
.POST(BodyPublishers.ofString(requestBody))
.build();
System.out.println("Sending POST request and connecting to SSE stream...");
try {
// Send the request and get a stream of lines
HttpResponse<Stream<String>> response = client.send(request, BodyHandlers.ofLines());
if (response.statusCode() == 200) {
System.out.println("Connection established. Processing events:");
// Process each line in the response body stream
response.body().forEach(line -> {
// Basic processing: check if the line starts with "data:" and print the content
if (line.startsWith("data:")) {
String data = line.substring(5).trim();
System.out.println("Received event data: " + data);
} else if (line.startsWith("event:")) {
String eventType = line.substring(6).trim();
System.out.println("Received event type: " + eventType);
}
// Handle 'id:' or 'retry:' lines for a more robust client (not shown here for brevity)
});
} else {
System.err.println("Failed to establish connection. Status code: " + response.statusCode());
}
} catch (Exception e) {
System.err.println("An error occurred: " + e.getMessage());
e.printStackTrace();
}
}
}
Note:
- The standard JDK
HttpClientdoes not automatically handle the SSE spec’sretry:field or implement automatic reconnection. For a production-ready client with these features, you may need a dedicated library. - I’ve implemented an
SSEEventSubscriber(which is aFlow.Subscriber<String>) that you can use instead of writing your own SSE stream processor. You can send the request using theBodyHandlers.fromLineSubscriber, passing theSSEEventSubscriberinstance (which also requires anEventHandlerthat will receive SSEEventfor client processing). The implementation implements the SSE specification (see the resource link below). The full source code is available on GitHub and you can use it in your project by adding this GAV to your Maven projectza.co.sindi:sindi-commons:<latest-version>.
4. Conclusion
Jakarta REST provides various solutions to handle client/server request and response both for synchronous and asynchronous processing. We’ve showcased various approaches to Server-Sent Events processing, especially outside of the usual HTTP GET operation (as specified on the SSE specification).
Maybe, in future, they should introduce SseEventSink to be injected on methods outside of @GET?
5. Links/resources.
- Jakarta RESTful Web Services (version 4.0.0 specification): https://jakarta.ee/specifications/restful-ws/4.0/jakarta-restful-ws-spec-4.0#asynchronous_processing.
- Server-Sent Events (specification): https://html.spec.whatwg.org/multipage/server-sent-events.html.

Interested in Learning More?
Buhake Sindi is a speaker at JCON. This article explores modern API development with Jakarta REST, including asynchronous HTTP POST Server-Sent Events – and his JCON session builds on these enterprise Java foundations by showing how Jakarta EE developers can integrate and deploy AI agents using the Google Agent2Agent protocol. If you can’t attend live, the session video will be available after the conference – it’s worth checking out!

This article is part of the JAVAPRO magazine issue:
Engineering Intelligence – Building Systems in the AI Age
Explore what engineering means in the AI age — beyond code generation and automation.
Gain insights into performance-critical Java systems, evolving architectures, and the responsibilities that come with building intelligent software.
Discover the edition →