Least-connections balancing chooses a peer using active connection counts, with the implementation’s weighting and tie rules. In nginx, least_conn accounts for server weights and uses weighted round robin among equally suitable peers. This can help when requests occupy connections for different durations. It is still a connection-based measurement, not direct knowledge of CPU, memory pressure, queued jobs, or database contention.

When it breaks

Calling the selected peer the “emptiest server” overstates the signal. One connection may carry an expensive operation while another carries a cheap wait. Background work can continue after an HTTP response closes, and multiplexed protocols can place several operations on one connection. A low connection count therefore does not prove that the backend has spare capacity for the next costly request.

The count is also the balancer’s observed upstream state, not an omniscient census of every backend client. Work arriving through another proxy or a direct internal path may be outside that view. In nginx, worker scope and upstream shared-zone configuration affect which runtime state is shared. Independent balancers do not automatically merge their observations into one global least-connections decision.

Weights make raw-count comparisons insufficient. A peer with weight two can appropriately carry more active connections than one with weight one. Changing a weight expresses a capacity assumption; it does not measure whether that assumption is true. An idle keepalive cache is another different quantity and should not be confused with the active connections used for this selection.

Check it quickly

Save this as check-least-connections.py and run it with Python, Docker, and the pinned images. The recorded setup uses one nginx 1.28.0 worker with no upstream keepalive cache and two HTTP peers on separate ports. The backend provides controlled response gates and active counters, making held work visible without depending on a guessed sleep duration:

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,extra_ports=()):
    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')
            published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))]
            docker('run','-d','--name',containers[0],'--network',name,*published,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;request.network=name
            request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value}
            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'
backend=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from urllib.parse import urlsplit,parse_qs
import itertools,json,threading
lock=threading.Lock();ids=itertools.count(1);counts={'A':0,'B':0};active={'A':0,'B':0};faults={'A':200,'B':200};gates={}
class Handler(BaseHTTPRequestHandler):
    protocol_version='HTTP/1.1'
    def setup(self):super().setup();self.ident=next(ids)
    def log_message(self,*args):pass
    def do_GET(self):
        parsed=urlsplit(self.path);label=self.server.label;code=200
        if label=='control':
            query=parse_qs(parsed.query)
            with lock:
                if parsed.path=='/fail':faults[query['peer'][0]]=int(query['status'][0])
                if parsed.path=='/release':gates[query['path'][0]].set()
                data={'counts':dict(counts),'active':dict(active),'faults':dict(faults),'held':list(gates)}
        else:
            with lock:
                counts[label]+=1;active[label]+=1;code=faults[label]
                if parsed.path.startswith('/hold/'):gates[parsed.path]=threading.Event()
            if parsed.path.startswith('/hold/'):assert gates[parsed.path].wait(20)
            data={'peer':label,'path':self.path,'connection':self.ident}
        body=json.dumps(data,sort_keys=True).encode();self.send_response(code)
        self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body)))
        self.end_headers();self.wfile.write(body)
        if label!='control':
            with lock:active[label]-=1
for port,label in [(8081,'A'),(8082,'B'),(8083,'control')]:
    server=ThreadingHTTPServer(('0.0.0.0',port),Handler);server.label=label
    threading.Thread(target=server.serve_forever,daemon=True).start()
threading.Event().wait()
'''
from concurrent.futures import ThreadPoolExecutor
extra='''
upstream least { least_conn; server backend:8081; server backend:8082; }
upstream weighted_least { least_conn; server backend:8081 weight=2; server backend:8082; }
'''
locations='''
location /least/ { proxy_pass http://least/; }
location /weighted/ { proxy_pass http://weighted_least/; }
location /control/ { proxy_pass http://backend:8083/; }
'''
with lab(config(locations,extra),backend=backend) as (_,request):
    def get(path):
        status,_,body=request(path);assert status==200;return json.loads(body)
    def state():return get('/control/state')
    results={}
    with ThreadPoolExecutor(max_workers=3) as pool:
        held=pool.submit(get,'/least/hold/first');wait(lambda:sum(state()['active'].values())==1)
        occupied=next(peer for peer,value in state()['active'].items() if value==1)
        quick=[get('/least/quick')['peer'] for _ in range(6)]
        assert all(peer!=occupied for peer in quick),quick
        get('/control/release?path=/hold/first');assert held.result()['peer']==occupied
        wait(lambda:sum(state()['active'].values())==0)
        results['held_peer']=occupied;results['quick_while_held']=quick
        futures=[]
        for index in range(3):
            futures.append(pool.submit(get,'/weighted/hold/w'+str(index)))
            wait(lambda:sum(state()['active'].values())==index+1)
        snapshot=state()['active'];assert snapshot=={'A':2,'B':1},snapshot
        for index in range(3):get('/control/release?path=/hold/w'+str(index))
        results['weighted_active']=snapshot;results['weighted_completed']=[f.result()['peer'] for f in futures]
    wait(lambda:sum(state()['active'].values())==0)
    print(json.dumps(results,sort_keys=True))

The first request enters the equally weighted least-connections group and waits inside one backend handler. The controller observes exactly one active request and identifies the occupied peer before sending anything else. Which peer wins the initial tie is not the important assertion; the fixture discovers that identity from the backend’s actual state.

Six short requests then complete through the same upstream group. All choose the other peer while the first request remains held. Only afterward does the controller release the original response and verify that it completes on its original peer. This demonstrates selection based on ongoing occupancy, rather than merely counting how many requests each server has answered over its lifetime.

The second group assigns weight two to A and weight one to B. Three requests are started one at a time, with each left unfinished and its arrival observed before the next starts. The active snapshot becomes A=2 and B=1. All three are then released and complete successfully, showing how weights alter the effective comparison instead of always preferring the smallest raw number.

The experiment’s HTTP exchanges keep one active request on each selected upstream connection. The two peer endpoints share a disposable container, so their names do not represent separate hardware capacity. The gates demonstrate occupancy and placement; they do not create a CPU benchmark or validate a particular weight for production machines.

Compare the observed metric with the resource you need to protect

Ask what keeps an upstream connection active in the real path. A slow client may hold it longer when response buffering is disabled, while a buffered proxy can sometimes finish reading upstream before that client finishes downloading. Changing buffering can therefore change the count seen by least connections without changing the application’s intrinsic computational cost.

Measure per-peer latency, errors, queueing, and resource use alongside connection counts. If one peer is slow because a dependency is failing, directing fewer new connections there can reduce pressure but does not repair it. Health eligibility and retry policy remain separate decisions. Least connections is not an active health probe or a gradual recovery ramp for a newly returned peer.

Finally, validate the scope and protocol of the deployed implementation. A method named least connections in another product can have different weighting, persistence, and state-sharing rules. Test a held request, release, and weighted competition through the actual balancer, and use the results to justify the selected metric rather than treating the algorithm’s name as a general capacity guarantee.

Sources