API Development: Implementing an HTTP POST/SSE event in Jakarta REST

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-stream HTTP header.
  • Server keeps connection open: The server responds with a Content-Type: text/event-stream header and keeps the connection alive (setting the Connection: 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-ID HTTP 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 protocol sequence diagram

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 multiple data: 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 the Last-Event-ID header 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 SseEventSink instance, injected into the resource method, represents the incoming connection from the client and is used to send the events.
  • The Sse instance is injected to serve as a factory for creating OutboundSseEvent instances and an SseBroadcaster (which we will view later).
  • The OutboundSseEvent instance 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 the Sse factory method newEvent(...), or via its Builder retrieved by the Sse.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 single SseBroadcaster, which can be obtained from the injected Sse‘s newBroadcaster() method. The OutboundSseEvent instance can, then, be broadcasted to all event sinks by calling the SseBroadcaster.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:

  1. Obtain a Client Instance: Start by creating a standard Client instance using ClientBuilder.
  2. Define the Target URL: Configure a WebTarget for the SSE endpoint.
  3. Build the SseEventSource: Use the SseEventSource.target(target).build() factory method to create an event source instance. You can also customise the reconnect delay if needed using reconnectingEvery().
  4. Register Event Consumers: Register consumers (lambda expressions or method references) to process different aspects of the event stream:
  • onEvent: A Consumer<InboundSseEvent> invoked for each received event.
  • onError: A Consumer<Throwable> invoked if an unrecoverable error occurs.
  • onComplete: A Runnable invoked when the stream naturally completes (e.g., server sends a 204 No Content status).
  1. Open the Connection: Start the connection to the server and begin processing incoming events using open(). The SseEventSource runs in its own internal thread and handles automatic reconnections by default.
  2. Manage the Lifecycle: The SseEventSource implements AutoCloseable, so it is best practice to use it within a try-with-resources block to ensure proper closure, or explicitly call eventSource.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 like readData() to access the event’s data payload and metadata like event ID, name, or retry interval.
  • Client and WebTarget: 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 AsyncResponse using the @Suspended annotation, 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 ManagedExecutorService in a Jakarta EE environment or a custom ExecutorService).
  • Resuming the Request: Once the long-running operation is complete, the application calls one of the resume() methods on the AsyncResponse instance. 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:

  1. Resource Setup: Create a RESTful resource method to handle the initial POST request, which will set up the SSE stream.
  2. Start Async: Inside the POST method, call request.startAsync() to get an AsyncContext, freeing the request thread.
  3. Event Listener: Register an AsyncListener to handle events like timeout or completion.
  4. Background Processing: Use an ExecutorService (e.g., from CDI or CompletableFuture) to process the POST data (e.g., save to DB) off the main thread.
  5. Complete/Dispatch: Call asyncContext.complete() when done, or asyncContext.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:

  1. Create an HttpClient instance.
  2. Build an HttpRequest for the POST method, including the body and the Accept: text/event-stream header.
  3. Send the request using BodyHandlers.ofLines() to get a Stream<String> which can then be processed line by line.
  4. 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:

  1. The standard JDK HttpClient does not automatically handle the SSE spec’s retry: field or implement automatic reconnection. For a production-ready client with these features, you may need a dedicated library.
  2. I’ve implemented an SSEEventSubscriber (which is a Flow.Subscriber<String>) that you can use instead of writing your own SSE stream processor. You can send the request using the BodyHandlers.fromLineSubscriber, passing the SSEEventSubscriber instance (which also requires an EventHandler that will receive SSE Event for 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 project za.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.

  1. 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.
  2. 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 

Total
0
Shares
Previous Post

BoxLang 1.14.0 : Introducing Inner Classes

Next Post

06-2026 | Autonomous Java

Related Posts