Response buffering lets nginx read from an upstream while delivering data to a client at another pace. With proxy_buffering on, nginx uses configured memory buffers and can write part of a large response to a temporary file. It need not collect the entire response before sending the first bytes. The available buffering determines how far the two connections can progress independently.
When it breaks
An unconditional promise that slow clients never block the backend is incorrect. Memory buffers, temporary-file limits, disk throughput, and socket buffers are finite. When nginx cannot absorb more data, the upstream can experience backpressure. Disabling temporary-file buffering does not turn response buffering off; it removes one place where data could wait, making that capacity boundary easier to reach.
Buffering can also interfere with an endpoint’s latency expectations. Small events may be accumulated before a client sees them, even though complete-response collection is not required. Streaming endpoints should choose response buffering deliberately and test event visibility through the full chain. Application buffering, compression, TLS, and other intermediaries can still affect delivery after nginx’s response buffering is disabled.
The resource tradeoff occurs at the proxy. Spooling responses can release backend work sooner but consumes temporary storage and I/O while clients drain. Growing memory buffers for every connection can create a different capacity problem under concurrency. The useful question is how much data the proxy should absorb for the workload, not whether a large single buffer sounds faster.
Check it quickly
Save the script as check-response-buffering.py and run it with Python, Docker, the pinned images, and spare temporary disk capacity. The recorded environment used nginx 1.28.0 on Linux arm64. A private backend exposes controlled response gates and progress counters; nginx listens only through host loopback. The experiment sends three 32 MiB bodies and cleans up its resources:
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid
NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def wait(predicate):
deadline=time.monotonic()+15
while time.monotonic()<deadline:
try:
if predicate():return
except OSError:pass
time.sleep(.1)
raise AssertionError('fixture did not become ready')
@contextmanager
def lab(configuration,files=None,backend=None):
name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
for path,content in (files or {}).items():
target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(content if isinstance(content,bytes) else content.encode())
docker('network','create',name)
if backend is not None:
(root/'backend.py').write_text(backend)
(root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
(root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
docker('build','--network','none','-t',tags[0],str(root))
docker('run','--rm','--network',name,tags[0],'nginx','-t')
docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0])
details=json.loads(docker('inspect',containers[0]).stdout)[0]
binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
def ready():
connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
wait(ready)
def request(path,method='GET',headers=None,body=None):
connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
try:
connection.request(method,path,body=body,headers=headers or {})
response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
finally:connection.close()
request.port=port
yield containers[0],request
finally:
docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)
def config(locations,extra=''):
return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
import hashlib
backend=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from urllib.parse import urlsplit,parse_qs
import json,socket,threading
lock=threading.Lock();state={};gates={};chunk=b'x'*65536;total=32*1024*1024
class Handler(BaseHTTPRequestHandler):
protocol_version='HTTP/1.1'
def log_message(self,*args):pass
def do_GET(self):
parsed=urlsplit(self.path);path=parsed.path
if path=='/state':
with lock:data=json.dumps(state).encode()
self.send_response(200);self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data);return
if path=='/release':
target=parse_qs(parsed.query)['path'][0]
with lock:gates[target].set()
self.send_response(204);self.end_headers();return
gated=path.startswith('/early-');size=2*len(chunk) if gated else total
with lock:state[path]={'written':0,'done':False};gates[path]=threading.Event()
self.connection.setsockopt(socket.SOL_SOCKET,socket.SO_SNDBUF,65536)
self.send_response(200);self.send_header('Content-Length',str(size));self.end_headers()
for offset in range(0,size,len(chunk)):
self.wfile.write(chunk)
with lock:state[path]['written']=offset+len(chunk)
if gated and offset==0:assert gates[path].wait(15)
with lock:state[path]['done']=True
ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''
locations='''
location /early-on { proxy_buffering on; proxy_pass http://backend:8081; }
location /early-off { proxy_buffering off; proxy_pass http://backend:8081; }
location /large-on { proxy_buffering on; proxy_pass http://backend:8081; }
location /large-off { proxy_buffering off; proxy_pass http://backend:8081; }
location /large-nodisk { proxy_buffering on; proxy_max_temp_file_size 0; proxy_pass http://backend:8081; }
location / { proxy_pass http://backend:8081; }
'''
extra='''
proxy_buffer_size 4k; proxy_buffers 4 4k; proxy_busy_buffers_size 8k;
proxy_temp_file_write_size 8k; proxy_max_temp_file_size 64m; proxy_temp_path /tmp/proxy;
'''
with lab(config(locations,extra),backend=backend) as (proxy,request):
results={}
def state():return json.loads(request('/state')[2])
for mode in ('on','off'):
path='/early-'+mode;connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=10)
connection.request('GET',path);response=connection.getresponse();first=response.read(1024)
snapshot=state()[path];assert response.status==200 and first==b'x'*1024 and not snapshot['done']
assert request('/release?path='+path)[0]==204
rest=response.read();connection.close();assert first+rest==b'x'*(2*65536)
results[path]={'first_bytes_before_backend_completion':len(first),'backend_written_at_first_read':snapshot['written']}
total=32*1024*1024;expected=hashlib.sha256(b'x'*total).hexdigest()
for mode in ('on','off','nodisk'):
path='/large-'+mode;sock=socket.socket();sock.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,65536)
sock.settimeout(20);sock.connect(('127.0.0.1',request.port))
connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=20);connection.sock=sock
connection.request('GET',path);response=connection.getresponse();assert response.status==200
wait(lambda:state().get(path,{}).get('written',0)>0)
if mode=='on':wait(lambda:state()[path]['done'])
else:
previous=-1;stable_since=time.monotonic();deadline=time.monotonic()+10
while time.monotonic()<deadline:
current=state()[path];assert not current['done'],current
if current['written']!=previous:previous=current['written'];stable_since=time.monotonic()
if time.monotonic()-stable_since>1:break
time.sleep(.05)
else:raise AssertionError('backend did not reach observable backpressure')
before=state()[path]
if mode!='on':assert 0<before['written']<total
sock.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,1024*1024)
digest=hashlib.sha256();received=0
while block:=response.read(1024*1024):digest.update(block);received+=len(block)
connection.close();wait(lambda:state()[path]['done'])
assert received==total and digest.hexdigest()==expected
results[path]={'before_client_drain':before,'received_bytes':received,'sha256':digest.hexdigest()}
log=docker('exec',proxy,'cat','/tmp/error.log').stdout
spills=[line for line in log.splitlines() if 'buffered to a temporary file' in line]
assert any('/large-on ' in line for line in spills),log
assert not any('/large-off ' in line or '/large-nodisk ' in line for line in spills)
results['temporary_file_used_for_large_on']=True
print(json.dumps(results,sort_keys=True))
First, the backend writes 65,536 bytes of a 131,072-byte response, then waits for an explicit release. In both buffering modes, the client reads 1,024 body bytes while the backend remains unfinished. Only afterward does the script release the second half and check the full response. This directly disproves the idea that enabled response buffering always delays delivery until the complete upstream response exists.
Next, a client receives headers for a 32 MiB response and pauses body consumption. With response buffering enabled and a 64 MiB temporary-file limit, the backend records that it finished writing its entire response before the client drains it. nginx’s error log confirms that the response used a temporary file. Backend progress and the actual spooling message support the conclusion together.
With buffering disabled, the backend’s completed-write counter stops below 32 MiB while the client remains paused. The fixture requires that counter to stay unchanged for a controlled interval, then resumes reading. The backend finishes and the complete digest matches. A second control leaves buffering enabled but sets proxy_max_temp_file_size 0; it also reaches observable backpressure before client consumption resumes.
The exact intermediate byte counts vary with socket and platform behavior. They are observations, not recommended buffer sizes or portable thresholds. The meaningful distinction is that one path finishes producing the response while the client is paused, whereas the two constrained paths do not. All three eventually deliver exactly 33,554,432 bytes with the same expected SHA-256 digest.
Budget buffering and verify visible progress
proxy_buffer_size handles the first upstream response portion, normally including headers. proxy_buffers configures additional response buffers, while temporary-file directives govern disk use for this uncached path. Do not confuse their limits with a maximum accepted response length: a response can continue as the client drains and frees capacity instead of being rejected merely for exceeding memory buffers.
An upstream may request response buffering changes with X-Accel-Buffering, unless nginx is configured to ignore that header. Audit that application-controlled behavior when a location’s expected policy seems ineffective. Request-body buffering is a separate setting and is not changed by this response control.
For an ordinary download, consider backend occupancy together with proxy disk use and client completion time. For progressive events, measure when each event becomes visible and include slow readers in the check. A fast localhost transfer alone cannot demonstrate protection against a slow-client workload or prove that streaming latency meets the application’s contract.
Sources
- nginx proxy module, response buffers, temporary files, and X-Accel-Buffering controls, checked 2026-09-12.
- nginx reverse proxy guide, buffered and synchronous forwarding to clients, checked 2026-09-12.