An upload has two separate connections: client to nginx and nginx to the application. proxy_request_buffering on reads the complete client request body before sending that request upstream. Turning it off lets nginx forward body bytes as they arrive. This changes when the application becomes occupied; it does not create unlimited capacity or make a slow sender disappear.

When it breaks

Disabling the wrong directive is a common mistake. proxy_buffering controls responses, while proxy_request_buffering controls uploads. A streaming upload can still wait at the proxy when only response buffering was disabled. Conversely, an application that should receive complete small submissions can become tied to slow clients when request buffering is disabled globally without considering backend concurrency.

Buffering transfers resource use to nginx. A request body that exceeds the configured memory buffer can use client body temporary storage. Many simultaneous uploads therefore consume proxy connections and potentially disk space or I/O. Increasing a buffer is not a substitute for choosing acceptable body sizes, upload timeouts, concurrency limits, and a temporary filesystem with enough capacity.

Streaming also changes failure handling. Once nginx has begun sending the request body upstream, it cannot simply pass that streamed request to the next server. An application may already have processed a prefix or produced side effects. Buffering can preserve body bytes for some retry paths, but it cannot make an unsafe application operation safe to replay.

Check it quickly

Save this as check-upload.py and run it with host Python and Docker after obtaining the pinned images. The recorded setup used nginx 1.28.0 and Python 3.11.16 containers on Linux arm64. The script creates a private backend network, publishes nginx only on host loopback, and cleans up its own containers, image tags, and network:

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
import hashlib,json,threading
state={};lock=threading.Lock()
class Handler(BaseHTTPRequestHandler):
    protocol_version='HTTP/1.1'
    def log_message(self,*args):pass
    def reply(self,data):
        data=json.dumps(data).encode();self.send_response(200)
        self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data)
    def do_GET(self):
        with lock:self.reply(dict(state))
    def do_POST(self):
        length=int(self.headers['Content-Length'])
        with lock:state[self.path]={'entered':True,'bytes':0}
        first=self.rfile.read(1)
        with lock:state[self.path]['bytes']=len(first)
        body=first+self.rfile.read(length-1)
        with lock:state[self.path]['bytes']=len(body)
        self.reply({'length':len(body),'sha256':hashlib.sha256(body).hexdigest()})
ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''
locations='''
location /on { proxy_request_buffering on; proxy_pass http://backend:8081; }
location /off { proxy_request_buffering off; proxy_pass http://backend:8081; }
location /state { proxy_pass http://backend:8081; }
'''
with lab(config(locations,'client_max_body_size 1m; proxy_http_version 1.1;'),backend=backend) as (_,request):
    payload=b'x'*65536;results={}
    def state():return json.loads(request('/state')[2])
    for mode in ('on','off'):
        connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=10)
        connection.putrequest('POST','/'+mode);connection.putheader('Content-Length',str(len(payload)))
        connection.endheaders();connection.send(payload[:1024])
        if mode=='off':wait(lambda:state().get('/off',{}).get('bytes')==1)
        else:
            until=time.monotonic()+1
            while time.monotonic()<until:
                assert '/on' not in state();time.sleep(.05)
        before=state().get('/'+mode)
        connection.send(payload[1024:]);response=connection.getresponse();data=json.loads(response.read());connection.close()
        assert response.status==200 and data=={'length':65536,'sha256':hashlib.sha256(payload).hexdigest()}
        results[mode]={'before_completion':before,'result':data}
    connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=5)
    connection.putrequest('POST','/off-too-large');connection.putheader('Content-Length',str(2*1024*1024))
    connection.endheaders();response=connection.getresponse();response.read();connection.close()
    assert response.status==413 and '/off-too-large' not in state()
    results['oversize_status']=response.status
    print(json.dumps(results,sort_keys=True))

The client declares a 65,536-byte body but initially sends only 1,024 bytes. With buffering enabled, repeated backend state checks show that the upload handler has not been entered during the controlled pause. The client has started transmitting, yet the application has not been asked to consume the incomplete body. Observing backend state makes this distinction clearer than comparing total request durations.

With buffering disabled, the backend enters its handler and records reading the first byte before the client sends the remainder. It then waits for the rest of the declared body. This demonstrates actual forwarding during the upload, rather than merely confirming that nginx accepts the directive. The backend’s deliberately simple read sequence makes that intermediate state visible.

After the remaining bytes arrive, both modes return length 65,536 and the same expected SHA-256 digest. The buffered and streamed paths therefore preserve this request body despite different timing. These assertions check received bytes, not just a successful status. They do not claim that every application parser, storage operation, or disconnect recovery policy has been exercised.

A separate request announces a two-megabyte body against a one-megabyte limit. nginx returns 413 before the client sends that body, and no matching backend handler appears. This is a size policy applied at the proxy; disabling request buffering does not disable it. The example deliberately uses known Content-Length framing so the admission decision is directly observable.

Choose the upload contract before tuning buffers

Use buffered uploads when keeping slow senders away from scarce application workers is useful and proxy storage is acceptable. Use streaming where the application must process incoming data progressively, while accounting for occupied backend resources and incomplete submissions. Neither mode promises that writers can never be slowed by downstream capacity.

Keep request and response policies independent. An endpoint might accept a buffered upload and stream progress afterward, or stream an upload and return a small buffered result. X-Accel-Buffering is an upstream response control; it does not replace the request-body policy demonstrated here.

Finally, inspect framing and the upstream protocol when extending the experiment. In nginx 1.28.0, an HTTP/1.1 chunked client upload still requires HTTP/1.1 proxying to avoid mandatory buffering in that path. This fixture explicitly selects upstream HTTP/1.1 but tests fixed-length bodies. Validate chunked bodies and disconnect behavior separately when those are part of the endpoint’s real contract.

Sources

  • nginx proxy module, request buffering, streamed request replay limits, and upstream HTTP version, checked 2026-09-12.
  • nginx HTTP core module, client body size limits, memory buffers, and temporary storage, checked 2026-09-12.
  • RFC 9110 HTTP Semantics, Content-Length and the meaning of a 413 response, checked 2026-09-12.