Skip to content

fix(push): stop NGINX buffering the server-sent events stream - #25751

Draft
mshabarov wants to merge 2 commits into
mainfrom
sse-and-proxy
Draft

mshabarov wants to merge 2 commits into
mainfrom
sse-and-proxy

Conversation

@mshabarov

Copy link
Copy Markdown
Contributor

Follow-up to #24484

behavior deviation · flow-server · apps using the experimental
server-sent events push transport behind NGINX

Background — proxy response buffering. A reverse proxy normally
collects a response from the backend before passing it on, which is fine
for a response that ends but not for a stream that stays open. NGINX reads
X-Accel-Buffering: no from the backend and turns buffering off for that
one response.

With the server-sent events push transport, an application behind an NGINX
that terminates TLS never receives a push message and the view simply stops
updating. NGINX holds the whole stream in its buffers with its default
settings. Until now the only way out was a change to the proxy
configuration, which the application cannot make.

Risks:

  • ⚠️ Behaviour change: push responses for this transport now carry one extra
    header. NGINX consumes it, and other proxies ignore a header they do not
    know.
  • ✅ No API change, no breaking change, no migration, nothing held per UI or
    session, no change to threading or to what is serialized.

Context. Atmosphere writes the content type and the cache headers for
an event stream itself but never sets X-Accel-Buffering, and Flow added no
headers of its own on the push path, so nothing told NGINX to leave the
stream alone.

  • Set X-Accel-Buffering: no on server-sent events push responses, before
    Atmosphere writes anything, because the header cannot be added once the
    response is committed.
    • Only this transport is marked. The others either finish each response,
      so a proxy flushes it anyway, or are not HTTP responses at all.
    • The transport is read from the X-Atmosphere-Transport query parameter
      and falls back to the header of the same name, since the Flow client
      sends it in the query string while an Atmosphere client may send a
      header.
  • Documented on Transport.SERVER_SENT_EVENTS that over HTTP/1.1 an open
    event stream occupies one of the six connections a browser allows per
    origin, so the sixth tab of the same application fails to load rather than
    only losing push. HTTP/2 lifts the limit and WebSocket is not affected.
  • Added PushRequestHandlerTest, covering the transport in the query
    parameter and in the header, and the transports that must be left alone.

Checked against the 44 reverse proxy scenarios in
https://github.com/mcollovati/vaadin-reverse-proxy-tests, driving a real
browser over all four transports. NGINX over HTTPS went from no push at all
to a stream that arrives tick by tick, and Apache HTTPD over HTTP, AJP and
HTTPS as well as NGINX over HTTP are unchanged. The per-scenario numbers are
in the first comment.

An NGINX that terminates TLS holds the whole event stream in its buffers
with its default settings, so no push message reaches the browser at all.
Atmosphere never sets X-Accel-Buffering, the header NGINX reads to turn
buffering off for a single response, so Flow sets it on the push response
itself.

Checked against the scenarios in mcollovati/vaadin-reverse-proxy-tests:
NGINX over HTTPS went from no push at all to a working stream, and Apache
HTTPD over HTTP, AJP and HTTPS as well as NGINX over HTTP are unchanged.
Over HTTP/1.1 an open event stream occupies one of the six connections a
browser allows per origin, so the sixth tab of the same application fails
to load rather than only losing push. WebSocket is not affected.
@mshabarov

Copy link
Copy Markdown
Contributor Author

Type of change

  • Bugfix

How to test

A proxy is needed to see the difference, so this uses the scenario suite
from https://github.com/mcollovati/vaadin-reverse-proxy-tests.

  1. Build a my-app image whose flow-server comes from this branch, and
    enable com.vaadin.experimental.ssePushTransport.
  2. Start the nginx/https/root-context scenario with docker compose up.
  3. Open https://localhost:9443/hello-flow, switch the push transport to
    SERVER_SENT_EVENTS and press Say hello in many languages.
  4. The four greetings appear one per second. Without this change no
    notification appears at all.

For a check without a proxy, open
flow-tests/test-root-context/src/main/java/com/vaadin/flow/uitest/ui/push/BasicPushSseView.java
and press Start timer; the server counter keeps rising, as before.

Why TLS makes the difference

Same NGINX, same application, one variable at a time. The counter is ten
server-pushed messages, 300 ms apart.

NGINX configuration Messages received
plain HTTP, proxy_buffering on (default) 10 of 10
TLS, proxy_buffering on (default) 0 of 10
TLS, proxy_buffering on, ssl_buffer_size 4k 3 of 10 after 12 s
TLS, proxy_buffering off 10 of 10
TLS, proxy_buffering on, backend sends X-Accel-Buffering: no 10 of 10

The last row is what this PR does, and it is the only one that does not ask
the user to change the proxy configuration.

NGINX logged the push request as 499 0 — the client gave up and zero bytes
had been sent. WebSocket and long polling pass on the same scenario, because
NGINX flushes an upgraded connection per frame and a long poll response ends
on its own.

Test coverage

PushRequestHandlerTest

Test What it verifies
serverSentEventsRequest_proxyBufferingDisabled the header is set whether the transport arrives as a query parameter or as a request header
otherTransports_responseNotTouched websocket, long-polling and a request with no transport leave the response untouched

Not covered by a unit test: that the header reaches the wire early enough to
matter. That is what the proxy runs below check.

Reverse proxy results

All 44 scenarios of the suite, each driving Chromium over WEBSOCKET_XHR,
WEBSOCKET, LONG_POLLING and SERVER_SENT_EVENTS. The test disables the
Atmosphere fallback, so a transport a proxy breaks fails instead of quietly
dropping to long polling, and it timestamps every counter update in the
browser, so a proxy that buffers shows up as messages arriving in one burst.
The figure is the time over which the ten messages arrived; the server sent
them over 3000 ms, and 3030 ms is the measurement with no proxy at all.

Proxy Scenarios SSE before SSE after
Apache HTTPD over HTTP 14 pass, 3046–3070 ms unchanged
Apache HTTPD over AJP 13 pass, 3048–3070 ms unchanged
Apache HTTPD over HTTPS, and AJP with HTTPS 2 pass, 3052 / 3069 ms unchanged
NGINX over HTTP 14 pass, 3056–3090 ms unchanged
NGINX over HTTPS 1 fail, no messages pass, 3048 ms

The scenarios cover root and custom context paths, a custom servlet mapping,
a relocated push URL, load balancing with sticky sessions and two
applications behind one proxy.

Worth noting for the AJP rows: those scenarios carry push over ProxyPass ajp://, because their rewrite only diverts requests that ask for a WebSocket
upgrade. So server-sent events work over AJP, which cannot carry WebSocket at
all — that is a real gain of the new transport.

Two findings that are not fixed here

Changing the transport at runtime does not move an open connection. The
client listens for changes to pushMode only, so setTransport on a UI
whose push connection is already open updates the server side while the
browser keeps using the transport it connected with. Pre-existing behaviour,
unrelated to this fix, but it means @Push and @CustomPush are the only
reliable way to select server-sent events, and the runtime example in
#24484 only works while push is off.

Idle connections. Push survives a 75 s idle period on both proxies. On
NGINX, server-sent events and long polling are both cut at the default 60 s
proxy_read_timeout and log a network error before Atmosphere reconnects,
while WebSocket stays quiet. Server-sent events are no worse than long
polling here, and raising proxy_read_timeout settles it.

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

 1 448 files   - 4   1 532 suites   - 4   1h 30m 49s ⏱️ - 7m 30s
11 899 tests  - 1  11 831 ✅  - 1  68 💤 ±0  0 ❌ ±0 
12 215 runs   - 3  12 147 ✅  - 3  68 💤 ±0  0 ❌ ±0 

Results for commit 51465dd. ± Comparison against base commit a2e6fca.

@Artur-

Artur- commented Sep 16, 2026

Copy link
Copy Markdown
Member

Could this be tested in flow-tests/vaadin-spring-tests/test-spring-boot-reverseproxy ?

@mcollovati

mcollovati commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

I'd avoid setting X-Accel-Buffering automatically: it's an nginx-specific convention that only helps when nginx is the immediate upstream hop, so it doesn't remove the need for proxy-side config (e.g. proxy_buffering off) in any topology with another hop in front, and it adds no value for Apache/AJP or other proxies while coupling the framework to one vendor's dialect. This is better left as something users configure on their own proxy (vaadin/docs#6059 covers this)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants