Skip to content

🐛 Set X-Accel-Buffering: no on JSONL streaming responses - #15813

Open
torrresagus wants to merge 3 commits into
fastapi:masterfrom
torrresagus:fix-jsonl-streaming-buffering-headers
Open

torrresagus wants to merge 3 commits into
fastapi:masterfrom
torrresagus:fix-jsonl-streaming-buffering-headers

Conversation

@torrresagus

@torrresagus torrresagus commented Jun 20, 2026 •

Copy link
Copy Markdown

What's wrong

FastAPI's Server-Sent Events responses set X-Accel-Buffering: no so streaming survives a buffering proxy (the is_sse_stream branch in fastapi/routing.py, added in #15030):

# For Nginx proxies to not buffer server sent events
response.headers["X-Accel-Buffering"] = "no"

The JSON Lines streaming responses (the yield-based application/jsonl streaming added in #15022, the is_json_stream branch) stream items incrementally in the same way, but don't set it.

Behind a proxy that buffers by default (e.g. Nginx with proxy_buffering on), the JSONL lines are buffered and flushed together, which defeats the streaming: the client receives the 200 immediately, but the lines don't arrive until the proxy buffer fills. SSE isn't affected because it already sends X-Accel-Buffering: no. The two features landed in separate PRs (SSE #15030, JSONL #15022), which is likely why only the SSE path got the header.

Change

Set X-Accel-Buffering: no on the JSONL StreamingResponse, so incremental streaming works through a buffering proxy the same way SSE does.

Cache-Control is intentionally not set: unlike SSE, JSON Lines is also used for bulk/streamed exports where caching can be legitimate, so the caching policy is left to the user (it can be set on the Response). This is documented in the JSON Lines tutorial. (Thanks @luzzodev for the review that clarified this.)

Tests

  • New test_stream_disables_proxy_buffering asserts x-accel-buffering: no across the four JSONL streaming tutorial variants (parametrized like the existing SSE streaming tests).
  • The existing SSE and streaming suites still pass.

Context

Discussed first in #15794, where the same failure mode was confirmed independently, including a report behind Nginx in production.

@codspeed

codspeed Bot commented Jun 20, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 24 untouched benchmarks


Comparing torrresagus:fix-jsonl-streaming-buffering-headers (dcee75e) with master (628663f)

Open in CodSpeed

@luzzodev

Copy link
Copy Markdown
Contributor

Hello @torrresagus, thanks for the PR! I understand the point, but I'm not fully convinced yet.

JSONL isn't 1 to 1 "SSE but in JSON", it's also used for plain bulk/streamed exports where caching can be legitimate. So I'd split the two headers:

  • X-Accel-Buffering: no. No objection, buffering defeats streaming in every case.
  • Cache-Control: no-cache, On this one I'm hesitant about. It blocks the max-age "serve from cache without hitting origin" case that an export endpoint might want.

In any case the picked behaviour should be clearly pointed out in the docs.

@torrresagus

Copy link
Copy Markdown
Author

Thanks for the careful review @luzzodev — that's a fair distinction, and you've convinced me.

You're right that JSONL isn't semantically "SSE in JSON": it's also a transport for bulk/streamed exports where caching can be legitimate. And since Cache-Control: no-cache forces revalidation rather than meaning "don't store", defaulting to it would quietly take away the max-age "serve from cache without hitting origin" option from those export endpoints. That's a policy call the framework shouldn't make on the user's behalf — anyone who does want it can still set it explicitly via the Response.

X-Accel-Buffering: no is the one that's unambiguous: buffering defeats incremental streaming in every case, export or live.

So I'll drop Cache-Control and keep only X-Accel-Buffering: no, and add a short note in the JSONL streaming docs spelling out that the response disables proxy buffering (and that caching headers are left to the user). I'll update the test to match and push shortly.

@github-actions

Copy link
Copy Markdown
Contributor

@luzzodev

Copy link
Copy Markdown
Contributor

LGTM!

@torrresagus

Copy link
Copy Markdown
Author

Thanks for the review @luzzodev!

@luzzodev luzzodev added the bug Something isn't working label Jun 23, 2026

@mohamedtaqysalmi mohamedtaqysalmi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mirroring the SSE anti-buffering behavior for JSONL is the right fix behind Nginx, and intentionally not imposing Cache-Control preserves legitimate export caching — the new docs section explains that well.

Small inconsistency: the PR title mentions both Cache-Control and X-Accel-Buffering, but
outing.py only sets X-Accel-Buffering. Either add the header for SSE parity or tighten the title/description so reviewers aren't looking for a header that isn't in the diff.

@torrresagus torrresagus changed the title 🐛 Set anti-buffering headers (Cache-Control, X-Accel-Buffering) on JSONL streaming responses 🐛 Set X-Accel-Buffering: no on JSONL streaming responses Jun 23, 2026
@torrresagus

Copy link
Copy Markdown
Author

Good catch @mohamedtaqysalmi — the title and description were stale after I dropped Cache-Control per the review. Re-adding it isn't the intent (it's deliberately left to the user), so I tightened the title and description to match the diff: only X-Accel-Buffering: no is set now. Thanks!

@torrresagus

Copy link
Copy Markdown
Author

@tiangolo Let me know if you need anything else from me.

Comment thread fastapi/routing.py Outdated
# For Nginx proxies to not buffer the streamed response.
# Caching headers are intentionally left to the user, as JSONL
# is also used for bulk exports where caching can be legitimate.
response.headers["X-Accel-Buffering"] = "no"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this is set before raw.extend, a user who sets X-Accel-Buffering on their own response ends up with two copies of the header instead of overriding it. intentional that the endpoint can't turn buffering back on?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, you're right. raw.extend appends, so a user-set X-Accel-Buffering ended up as a second header instead of overriding it.

It wasn't a deliberate "you can't turn buffering back on" decision, I had just mirrored the SSE branch a few lines above, which does the same thing with Cache-Control and X-Accel-Buffering.

Fixed in fe13265 by setting the header after the extend:

response.headers.raw.extend(solved_result.response.headers.raw)
response.headers.setdefault("X-Accel-Buffering", "no")

Now the default is still no, but a path operation that sets the header itself wins, and there's only one copy of it. Added a test for both cases in tests/test_stream_json_lines_headers.py and a line in the docs.

One note while testing it: the header has to be set from a dependency, not from inside the generator body, since the headers are already sent by the time the first item is yielded.

I left the SSE branch as is, changing its behavior feels out of scope for this PR, but happy to do it here too if a maintainer prefers to keep both consistent.

AdarshJ173 added a commit to AdarshJ173/fastapi that referenced this pull request Jul 21, 2026
Fixes parity gap between SSE and JSONL streaming branches.

The SSE branch in get_request_handler() already sets:

    response.headers["X-Accel-Buffering"] = "no"

to prevent Nginx (and other buffering proxies) from buffering the
event stream. Without this header, proxies configured with
`proxy_buffering on` (the Nginx default) will hold chunks until
their buffer fills before flushing to the client — causing large
latency spikes or no visible streaming at all.

The JSONL branch streams in an identical way (incremental line-by-line
yield, chunked transfer encoding), yet lacked this header. The result:

  @app.get("/stream")
  async def stream() -> AsyncIterable[Item]:
      for item in items:
          yield item
          await asyncio.sleep(0.1)

Behind Nginx with proxy_buffering on:
  • SSE:  client sees events as they arrive          ✔
  • JSONL: client receives all lines in one flush    ✘

Fix: apply the same header to the JSONL StreamingResponse.

Cache-Control is intentionally NOT set on JSONL — unlike SSE, JSONL
is also used for bulk/streamed file exports where caching may be
legitimate. Users who need no-cache on JSONL can set it via a
Response dependency parameter.

Also add a TODO comment in the SSE keepalive code noting that the two
branches (SSE + JSONL) now share the same proxy-buffering contract and
should be kept in sync.

Relates to: fastapi#15813
Relates to: fastapi#15794
@github-actions

This comment was marked as resolved.

@github-actions github-actions Bot added the conflicts Automatically generated when a PR has a merge conflict label Jul 29, 2026
Server-Sent Events responses set `Cache-Control: no-cache` and
`X-Accel-Buffering: no` so proxies (e.g. Nginx) deliver events
incrementally instead of buffering the whole response.

JSONL streaming responses are incremental in the same way, but were
missing these headers, so a buffering proxy would hold back the lines
and defeat the streaming. Set the same headers on the JSONL response
for consistency with SSE.
…g, document behavior

Per review feedback: JSON Lines is not only a live event stream like SSE,
it's also used for bulk/streamed exports where caching can be legitimate.
`Cache-Control: no-cache` forces revalidation and would take away the
`max-age` "serve from cache" option from those endpoints, so it shouldn't
be imposed by default.

Keep only `X-Accel-Buffering: no` (buffering defeats incremental streaming
in every case) and leave caching headers to the user. Document the proxy
buffering behavior in the JSON Lines tutorial.
@torrresagus
torrresagus force-pushed the fix-jsonl-streaming-buffering-headers branch from fe13265 to dcee75e Compare July 29, 2026 03:00
@github-actions github-actions Bot removed the conflicts Automatically generated when a PR has a merge conflict label Jul 29, 2026
TrueFurina added a commit to TrueFurina/fastapi that referenced this pull request Aug 27, 2026
@vamshi256

Copy link
Copy Markdown

I tried reproducing this locally with FastAPI behind Nginx on Windows. I configured proxy_buffering on and used a StreamingResponse producing JSONL chunks at 1-second intervals. The client still received each chunk incrementally rather than receiving them all at once.

I also noticed that the FastAPI version I tested already sets X-Accel-Buffering: no for the response in routing.py. Because of this, I'm wondering if the behavior described in this PR has already been addressed in the version I tested.

Could you clarify which FastAPI version/commit was used for the original reproduction?

This branch has not been deployed

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants