Source: ddx-documentation/research/rq10-sse-through-bff.md
**Question** (BRIEF.md RQ10): which SSE envelope fits notifications and streaming through a Next.js route handler, and what breaks behind the BFF (buffering, timeouts)? This page is the wave-0 desk half of failure mode 6 ("the SSE stream buffers behind the Next.js route handler", `ddx-wip/job_ui-shell-rnd-workspace/03_EXECUTION.md`). The empirical half (a long-lived stream spike) runs in **task 031**. ## Findings | # | Finding | Source (search excerpts 2026-10-23) | |---|---|---| | F1 | A route handler takes a Web `Request` and returns a `Response`. For SSE, return a `Response` whose body is a `ReadableStream`. In the BFF, pass the upstream `fetch` body through as-is (`new Response(upstream.body, …)`); do not read it into memory. | <https://server-sent-events.com/backend-stream-generation-connection-management/nodejs-streaming-architecture-basics/streaming-sse-from-nextjs-route-handlers> (2026-09-18) | | F2 | Static optimisation can cache a GET handler at build time. The handler must be dynamic. Classic answer: `export const dynamic = 'force-dynamic'`. | same page; Next.js route handler docs <https://nextjs.org/docs/14/app/building-your-application/routing/route-handlers> | | F3 | **Conflict to verify:** this stack runs `cacheComponents: true` (VERSIONS.md `next16`), and the stack rule says "no segment configs". Under Cache Components a handler that reads the request (headers, cookies, `request.signal`) is dynamic without `force-dynamic`, and the `dynamic` segment export may be rejected. Task 031 checks which form builds. Do not add `force-dynamic` until it does. | `~/.claude/ddx-topic-flows/VERSIONS.md` `next16` row; stack-specialist "Next.js 16" rules | | F4 | Compression buffers SSE: a compressor holds a window of output before it flushes. Send `Content-Encoding` none for the stream path, and set `compress: false` on the Next.js server or exclude `text/event-stream` in any proxy. | vercel/next.js discussion #48427 <https://github.com/vercel/next.js/discussions/48427>; expressjs/compression SSE note (linked there) | | F5 | Reverse proxies (nginx) buffer by default. Send `X-Accel-Buffering: no`, plus `Cache-Control: no-cache, no-transform` and `Connection: keep-alive`. | <https://medium.com/@oyetoketoby80/fixing-slow-sse-server-sent-events-streaming-in-next-js-and-vercel-99f42fbdb996> (2026-01-18) | | F6 | The dev server and proxies can hold small chunks. Send a keep-alive comment line (`: ping`) on a fixed interval (for example every 15 s) so idle proxies do not cut the connection and chunks flush. | <https://dev.to/ahmed_mahmoud360/streaming-ai-responses-in-nextjs-sse-fetch-streams-and-what-breaks-in-production-4f76> (2026-07-23) | | F7 | Abort wiring: pass `request.signal` to the upstream `fetch`, and cancel the stream on abort. Otherwise ddx-api keeps a dead subscriber per closed tab. | server-sent-events.com page (F1); dev.to page (F6) | | F8 | Hosting limits end long functions. This workspace runs `next start` on the host (port 16300), not a serverless host, so no platform timeout applies. Node's default request/headers timeouts still apply. | medium.com page (F5); [../ports.md](/en/docs/ports) | | F9 | The envelope is not invented here: one client manager opens the only `EventSource` (`ddx-web/src/lib/sse/manager.ts`), no WebSocket. | `plans/ui-shell-rnd-workspace/_invariants.md` invariant 7; AC9.1 | ## Desk design for the BFF stream path 1. `GET api/v1/events` → BFF forwards to ddx-api with the session credential, `Accept: text/event-stream`, and `signal: request.signal`. 2. Response headers: `Content-Type: text/event-stream; charset=utf-8`, `Cache-Control: no-cache, no-transform`, `Connection: keep-alive`, `X-Accel-Buffering: no`. No `Content-Length`. No compression. 3. Body: the upstream `ReadableStream`, untouched. ddx-api writes the keep-alive comments. 4. Errors before the stream opens use the house error envelope and the real status (401 if no session — AC9.2 accepts 200 or 401). ## What task 031 must verify - [ ] First event arrives at the browser in < 1 s after ddx-api emits it (no buffering). - [ ] A stream stays open ≥ 5 min with keep-alive comments and no proxy cut. - [ ] Closing the tab aborts the upstream request (ddx-api subscriber count drops). - [ ] Which dynamic form builds under `cacheComponents: true` (F3). - [ ] Response carries no `Content-Encoding: gzip` / `br` on the stream path (F4). - [ ] An org-A event never reaches an org-B stream (AC9.3 spec). - [ ] Reconnect with `Last-Event-ID` resumes (00_JOB outcome 7). ## Measured (task 031) **Status: NOT MEASURED (2026-10-23).** `./ddx-manage.sh status` showed ddx-web 16300 and ddx-api 16310 down (`http 000`), and the task 031 dispatch forbids starting a dev server. Nothing below is a measured number. The source-level answers are recorded; the runtime checks stay open for the browser-verify lane (task 041). | Check | Answer (source) | Runtime | |---|---|---| | First event < 1 s | Body piped untouched (`new NextResponse(upstream.body)`), no read into memory; `cache-control: no-cache, no-transform`, `x-accel-buffering: no` on the BFF answer. | open | | Stream open ≥ 5 min | **Fails by source.** The route reuses `callUpstream` (the one upstream client). It always adds `AbortSignal.timeout(10 s)` to the fetch signal, and that abort also ends the response body. Expect the stream to close about every 10 s. The manager then reopens it with the last id, so no event is lost, but it is a reconnect loop. Fix belongs in `forward.server.ts` (owner task 021 fix lane): a stream option whose timeout covers the response headers only. | open | | Tab close aborts upstream | `request.signal` is passed into `callUpstream` → fetch; Nest unsubscribes on close (`sse.controller.ts`). | open | | Dynamic form under `cacheComponents` (F3) | No segment config in the route. Reading `request` makes it dynamic. `dynamic = 'force-dynamic'` is not added (not accepted with Cache Components). | build not run (lead gate) | | No `Content-Encoding` on the stream | `no-transform` makes Next's response compression skip the body; ddx-api mounts no compression. | open | | Org A event never reaches org B | Server-side filter in `SseHub` (task 027 spec `sse-org-scope.spec.ts`). The BFF relays no client tenant header. | open (task 041) | | Reconnect resumes with Last-Event-ID | Native retry sends the header; a manager reconnect sends `?lastEventId=` (EventSource cannot set headers) and the BFF maps it to `Last-Event-ID` after a `<epoch>.<seq>` shape check. The manager drops ids at or below the last seq of the same epoch (no duplicates after replay). | open | **Procedure for the lane that measures** (stack up through `./ddx-manage.sh`, signed in as a seeded doctor, cookies in `jar.txt`): ```sh # 1. status + headers through the BFF (expect 200 + text/event-stream, or 401 without cookies) curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' -b jar.txt -H 'accept: text/event-stream' \ --max-time 3 http://localhost:16300/api/v1/events # 2. first-event latency: keep a reader open, then post a thread message in the same org curl -sN -b jar.txt http://localhost:16300/api/v1/events | while IFS= read -r l; do printf '%s %s\n' "$(date +%s.%N)" "$l"; done # (other shell) POST /api/v1/orgs/<slug>/threads/<id>/messages and note its send time; # latency = first `event: entity.changed` timestamp − send time. Pass: < 1 s. # 3. lifetime: leave step 2 running 5 min; count stream ends (expected ~10 s cuts until the # forward.server.ts stream option lands). ``` The browser check: open `/en/portal/<slug>/doctor/stream`, DevTools Network → one `events` request (EventStream tab), status line shows "Live"; go offline and back → one new `events?lastEventId=…` request and no repeated rows in the notification panel.
Question (BRIEF.md RQ10): which SSE envelope fits notifications and streaming through a Next.js route handler, and what breaks behind the BFF (buffering, timeouts)?
This page is the wave-0 desk half of failure mode 6 ("the SSE stream buffers behind the
Next.js route handler", ddx-wip/job_ui-shell-rnd-workspace/03_EXECUTION.md). The
empirical half (a long-lived stream spike) runs in task 031.
GET api/v1/events → BFF forwards to ddx-api with the session credential, Accept: text/event-stream, and signal: request.signal.Content-Type: text/event-stream; charset=utf-8,
Cache-Control: no-cache, no-transform, Connection: keep-alive,
X-Accel-Buffering: no. No Content-Length. No compression.ReadableStream, untouched. ddx-api writes the keep-alive comments.cacheComponents: true (F3).Content-Encoding: gzip / br on the stream path (F4).Last-Event-ID resumes (00_JOB outcome 7).Status: NOT MEASURED (2026-10-23). ./ddx-manage.sh status showed ddx-web 16300 and
ddx-api 16310 down (http 000), and the task 031 dispatch forbids starting a dev server.
Nothing below is a measured number. The source-level answers are recorded; the runtime
checks stay open for the browser-verify lane (task 041).
Procedure for the lane that measures (stack up through ./ddx-manage.sh, signed in as a
seeded doctor, cookies in jar.txt):
The browser check: open /en/portal/<slug>/doctor/stream, DevTools Network → one
events request (EventStream tab), status line shows "Live"; go offline and back → one new
events?lastEventId=… request and no repeated rows in the notification panel.
adr/
deploy/
research/
# 1. status + headers through the BFF (expect 200 + text/event-stream, or 401 without cookies)curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' -b jar.txt -H 'accept: text/event-stream' \ --max-time 3 http://localhost:16300/api/v1/events# 2. first-event latency: keep a reader open, then post a thread message in the same orgcurl -sN -b jar.txt http://localhost:16300/api/v1/events | while IFS= read -r l; do printf '%s %s\n' "$(date +%s.%N)" "$l"; done# (other shell) POST /api/v1/orgs/<slug>/threads/<id>/messages and note its send time;# latency = first `event: entity.changed` timestamp − send time. Pass: < 1 s.# 3. lifetime: leave step 2 running 5 min; count stream ends (expected ~10 s cuts until the# forward.server.ts stream option lands).