Our staging environment had ten browser tabs open with the notification bell. The eleventh person to open the app got an API that just sat there. Not a 500. Not a timeout (yet). Just a silent stall on every endpoint, not only the SSE one. The pod looked healthy. CPU flat, memory fine, logs quiet.
We'd turned our notification bell into a Server-Sent Events stream while leaving one Spring Boot default in place. For a request-response API, that default is fine. For a 30-minute SSE connection, it's lethal: spring.jpa.open-in-view=true pins a Hikari connection per open tab for the entire lifetime of the stream.
Here's the bug, the math behind the symptom, and the one-line fix — plus the precondition the endpoint has to satisfy for the fix to be safe.
The Endpoint
The SSE endpoint lives in NotificationController.streamNotifications(). It returns a Flux<ServerSentEvent<NotificationStreamEvent>> to the client and stays open for 30 minutes, emitting the initial unread count, live updates from the event bus, and a heartbeat every 30 seconds:
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<Flux<ServerSentEvent<NotificationStreamEvent>>>
streamNotifications() {
authContext.requireScope("read");
UserEntity user = authContext.getCurrentUser();
Long userId = user.getId();
ServerSentEvent<NotificationStreamEvent> initial = ServerSentEvent
.<NotificationStreamEvent>builder()
.event("unread-count")
.data(NotificationStreamEvent.unreadCount(
notificationService.getUnreadCount(userId)))
.build();
Flux<ServerSentEvent<NotificationStreamEvent>> events =
notificationEventBus.subscribe(userId)
.map(event -> ServerSentEvent.<NotificationStreamEvent>builder()
.event(event.type()).data(event).build());
Flux<ServerSentEvent<NotificationStreamEvent>> heartbeat =
Flux.interval(HEARTBEAT_INTERVAL)
.map(tick -> ServerSentEvent.<NotificationStreamEvent>builder()
.comment("ping").build());
Flux<ServerSentEvent<NotificationStreamEvent>> body = Flux.concat(
Flux.just(initial), Flux.merge(events, heartbeat))
.take(STREAM_LIFETIME);
return ResponseEntity.ok().headers(headers).body(body);
}
Two numbers matter: STREAM_LIFETIME = Duration.ofMinutes(30) and HEARTBEAT_INTERVAL = Duration.ofSeconds(30). The stream stays open for half an hour. The client reconnects on close with a fresh JWT. The heartbeat keeps it alive through any proxy idle-timeout in between.
From the controller's perspective this looks innocuous. It's a Flux. It's reactive. It touches JPA once, synchronously, at the top, to seed the initial count. The Flux body that streams for the next 30 minutes never touches the database.
That's what we thought the runtime behavior was. The runtime had other ideas.
open-in-view: Spring's Helpful Default
spring.jpa.open-in-view=true is Spring Boot's default. It enables OSIV — Open Session In View. The mechanism is an interceptor that opens a Hibernate Session (and, through it, borrows a database connection from the Hikari pool) at the start of an HTTP request, and holds it open until the response is fully written.
For traditional MVC this is convenient. Lazy-loaded JPA associations still resolve when your Thymeleaf template or JSON serializer touches them after the controller returns. No LazyInitializationException — at the cost of holding a DB connection for the lifetime of the HTTP response.
For a request-response endpoint serving JSON in 50ms, "lifetime of the HTTP response" is 50ms. The connection round-trips back to the pool before the next request even arrives. Pool pressure is essentially zero and the convenience is large enough that the Spring team made it default-on.
Why It's Lethal for SSE
Server-Sent Events flips the lifetime assumption on its head.
The HTTP response on an SSE endpoint is not 50ms. It's the entire duration of the open stream — 30 minutes in our case. As long as the tab is open and the stream is live, the response is still being written one event at a time, and the servlet container hasn't released the request or the response.
OSIV doesn't know any of that. It just sees a request that's still in flight, so the session must still be open, so the connection it borrowed from Hikari must still be held.
One open SSE stream = one Hikari connection pinned for 30 minutes. The connection isn't being used. Nothing is running queries on it. It's reserved, sitting idle, unavailable to anyone else, until the response finally ends — at which point the user reconnects with a fresh JWT and the pinned-connection clock resets.
The Symptom Math
Hikari's default maximumPoolSize is 10. Spring Boot doesn't override it. Most apps don't either, because for a normal API the default is more than enough — connections cycle back in tens of milliseconds and you'd need hundreds of concurrent requests to feel any pressure.
Now run the math with OSIV + SSE:
| Open SSE tabs | Hikari connections held | Connections free for other work |
|---|---|---|
| 1 | 1 | 9 |
| 5 | 5 | 5 |
| 9 | 9 | 1 |
| 10 | 10 | 0 |
| 11 | 10 | 0 (request 11 waits) |
The 11th SSE subscriber's request asks OSIV for a connection. There are none. Hikari blocks the request thread on the pool, waiting for one to free up. Nothing will free up for the next 29 minutes and 50 seconds. Eventually Hikari hits its connection-timeout (default 30 seconds) and throws SQLTransientConnectionException: HikariPool-1 - Connection is not available.
The cruel part: it's not just the SSE endpoint that hangs. Every endpoint that needs a DB connection — login, the click handler, the dashboard fetch — competes for the same pool. Ten pinned SSE tabs from a single demo session, and the whole API is wedged. The JVM is alive, nothing is crashing, everything is just queuing on an empty pool. You stare at flat CPU graphs for an hour and feel personally betrayed by your observability stack.
The Fix
One line in application-database.yaml:
spring:
jpa:
open-in-view: false
That disables the OSIV interceptor. Connections are no longer held for the lifetime of the HTTP response — they're borrowed when a transaction starts, returned when it ends. The 30-minute SSE stream now holds zero connections for the 30 minutes it's open. Hikari pool is unblocked. The 11th tab works. The 1000th tab works.
Spring's startup log even warns about OSIV being on by default, but it's easy to scroll past in a noisy log. On a normal MVC app it's not a problem worth acting on. On an app with long-lived reactive endpoints, it absolutely is.
The Precondition That Makes the Fix Safe
You can't just flip open-in-view: false and call it done. Turning OSIV off changes a semantic: lazy-loaded JPA associations outside an explicit transaction now throw LazyInitializationException. The endpoint has to be structured so it doesn't depend on that lazy-load convenience.
Our SSE endpoint already is, intentionally. Look at where it touches JPA:
ServerSentEvent<NotificationStreamEvent> initial = ServerSentEvent
.<NotificationStreamEvent>builder()
.event("unread-count")
.data(NotificationStreamEvent.unreadCount(
notificationService.getUnreadCount(userId))) // <-- only JPA touch
.build();
That one call. It runs synchronously at subscribe-time inside the @Transactional boundary of NotificationService.getUnreadCount(...). The transaction opens, borrows a connection, runs the count query, returns a long, commits, releases the connection. Total time: a few milliseconds.
After that, every event flowing through the Flux body — live events from notificationEventBus.subscribe(userId), the heartbeat pings, the close after 30 minutes — emits a plain in-memory NotificationStreamEvent record. No JPA. No lazy proxies. Nothing the persistence context needs to resolve.
The Javadoc on the endpoint spells out this contract explicitly:
Pool contract: this endpoint relies on
spring.jpa.open-in-view=false(set inapplication-database.yaml). All JPA access happens at subscribe time vianotificationService.getUnreadCount(...)inside its own transaction. The Flux body emits the in-memoryNotificationStreamEventrecord only — no JPA touch — so no Hikari connection is held for theSTREAM_LIFETIMEwindow. If OSIV is ever re-enabled, every open browser tab pins a connection until the stream closes, exhausting the pool at low concurrency. Do not re-enable OSIV.
That paragraph exists because the fix is one config flag away from being silently undone by a well-meaning future contributor who reads "Spring Boot default" and decides to align. The Javadoc is the load-bearing comment for the entire endpoint.
Lessons Learned
- OSIV is a request-response convenience. It pays for itself in a 50ms JSON endpoint. It bankrupts you in a 30-minute SSE stream. The cost is "connection held for the lifetime of the HTTP response," and on a long-lived response that's the whole stream.
- Hikari's default pool of 10 is generous for request-response and tiny for anything that pins connections. The default implicitly assumes you're returning connections quickly. If you're not, you usually only learn this in production at the eleventh concurrent subscriber.
- Pool exhaustion makes everything hang, not just the slow endpoint. The DB pool is shared. SSE pinning it starves every other endpoint. The trail leads back to SSE only after you check pool stats.
spring.jpa.open-in-view: falseis right for any app with long-lived reactive endpoints, but only safe if those endpoints don't depend on lazy-loading outside an explicit transaction. Audit first; don't flip the flag blind.- Document the precondition where the bug would re-enter. A one-paragraph "Pool contract" Javadoc on the endpoint is the difference between a fix that holds and one someone undoes in six months while "cleaning up config to match Spring defaults."
- Reactive controllers on a servlet stack are a special kind of trap. Reactive types make you think in non-blocking lifetimes; the underlying servlet container is still tracking the HTTP request as in-flight for the full duration, and request-scoped infrastructure like OSIV happily plays along.
Has OSIV bitten you somewhere unexpected? Drop the symptom in the comments.
Building jo4.io — a URL shortener with analytics for developers who ship.