How HTTP Works

Request and response header

< Connection: keep-alive

Connection Header

Learn how the Connection header controls whether HTTP connections stay open (keep-alive) or close after each request. Optimize with persistent connections.

Reviewed 7 min readintermediate2 sourcesTry itMarkdown
Direction
Request and response
Category
Connection management
Spec
RFC 9110 §7.6.1
JS can set it
No: forbidden header name
Hop-by-hop
Yes: not forwarded by proxies
On this page

TL;DR: Connection controls the current HTTP/1.x connection. HTTP/1.1 keeps connections open by default, so you rarely need Connection: keep-alive; send Connection: close when you want the connection shut after this response. It also lists hop-by-hop headers that proxies must strip. HTTP/2 and HTTP/3 forbid it entirely.

Connection only covers the connection it travels on. A reverse proxy holds two separate connections, one to the client and one upstream. If a client sends Connection: close, that applies to the client-facing hop; the proxy makes its own choices for the upstream side.

How Connection Works

This request asks the server to close the connection once it has responded (examples on this page are trimmed):

GET / HTTP/1.1
Host: example.com
Connection: close

The connection closes after the response, not the moment the header arrives. Without close, either side may reuse the connection, but either side may also close it when it goes idle; nobody promises to keep it open forever. See RFC 9112 §9.3.

Syntax

The value is a comma-separated list of case-insensitive tokens. Listing a header name marks that header as connection-specific, as with Example-Hop here:

Connection: Example-Hop, close
Example-Hop: diagnostic-value

Before forwarding, an intermediary strips Connection, every header it names, and the other known connection-specific headers. RFC 9110 §7.6.1 defines the rules.

HTTP Version Differences

ProtocolConnection handling
HTTP/1.0Close by default; historical keep-alive extension is implementation-dependent.
HTTP/1.1Persistent by default; close ends reuse after the response.
HTTP/2 and HTTP/3Connection is forbidden, making the message malformed.

HTTP/1.0 closes after each response unless both sides support the old Connection: keep-alive extension. HTTP/1.1 flipped the default to persistent. HTTP/2 and HTTP/3 go further: a connection-specific header isn’t a harmless hint there, it makes the message malformed.

Common Examples

In an HTTP/1.1 WebSocket handshake, the client sends this pair:

Connection: Upgrade
Upgrade: websocket

That’s only part of the handshake. The request also needs the headers described on Sec-WebSocket-Key.

Real-World Scenarios

Draining an HTTP server

Connection: close on a response ends reuse of that one connection; the server keeps listening. To shut down a Node HTTP/1.1 service cleanly, call server.close(), which stops accepting new connections and waits for active requests. This complete Express example also adds Connection: close to responses started while draining:

import express from 'express'
import http from 'node:http'

const app = express()
let draining = false
app.use((req, res, next) => {
  if (draining) res.setHeader('Connection', 'close')
  next()
})
app.get('/health', (req, res) => {
  res.status(draining ? 503 : 200).json({ ready: !draining })
})
app.get('/', (req, res) => res.json({ status: 'ok' }))
const server = http.createServer(app)
server.listen(3000, '127.0.0.1')
process.once('SIGTERM', () => {
  draining = true
  server.close((error) => {
    if (error) console.error(error)
    process.exitCode = error ? 1 : 0
  })
})

Install Express with pnpm add express, save as app.mjs, and run node app.mjs. Node documents server.close(), including what it does with idle connections. Upgraded WebSocket connections need their own shutdown logic. Health checks are ordinary requests here; there’s no need to close connections just because a health check came in.

Long polling is an active request

A long-poll handler deliberately waits before responding. Keep-Alive: timeout=60 is about idle time between requests, so it says nothing about a 60-second wait inside the handler; that’s governed by the reverse proxy’s upstream read timeout, which you check separately. Once the poll returns, even as a 204 with no body, HTTP/1.1 can reuse the connection. So there are two different timeouts in play: one that fires while the poll is pending, and one that reclaims the idle connection afterwards. To tell them apart, log when the request enters the handler, when headers are written, and when the socket closes.

Keep-Alive Parameters

Keep-Alive can advertise idle-time and request-count hints, but sending the header changes nothing about how the server actually handles sockets. Configure the real limits on the server.

In Node, set the idle timeout and request count like this:

server.keepAliveTimeout = 5000 // chosen idle timeout, milliseconds
server.maxRequestsPerSocket = 100 // chosen requests per socket

These go on an existing http.Server and only affect Node’s side; a load balancer’s connection pool has its own settings. maxHeadersCount is unrelated despite the name: it limits the number of header fields in a message.

Behind a load balancer, make the backend’s idle timeout longer than the balancer’s. AWS ALB defaults to 60 seconds; if the app closes idle connections first, the balancer can send a request down a socket the app just closed and return a 502. For Node behind an ALB, server.keepAliveTimeout = 65000 is the usual setting. The nginx 502 guide walks through this race.

Getting Connection right

Keep application sessions and transport connections separate. A user logging out is no reason to close a socket; the two have different lifetimes. In browser JavaScript, Connection is a forbidden request header, so the browser manages it for you.

Connection Pooling

Node’s HTTP client reuses connections through an http.Agent, passed to http.get() or http.request(). This runnable example reads each response fully before sending the next request, then destroys the pool:

import http from 'node:http'

const agent = new http.Agent({ keepAlive: true })
function get(url) {
  return new Promise((resolve, reject) => {
    http.get(url, { agent }, (res) => {
      res.resume()
      res.on('end', resolve)
      res.on('error', reject)
    }).on('error', reject)
  })
}
try {
  await get('http://localhost:3000/')
  await get('http://localhost:3000/')
} finally {
  agent.destroy()
}

This is the node:http Agent API. Other clients, including various fetch() implementations, have their own pooling options, so check before passing an agent.

For a busy client, cap active and idle sockets while keeping reuse on. Swap the Agent declaration above for something like this:

const agent = new http.Agent({
  keepAlive: true,
  maxSockets: 20,
  maxFreeSockets: 5,
  maxTotalSockets: 40
})

maxSockets is per host, and maxTotalSockets covers every host the Agent talks to. Requests over the active limit wait in a queue. maxFreeSockets caps idle sockets in the pool; it doesn’t limit concurrency. Pick numbers that fit your traffic; these are examples.

In the browser, the browser owns the pool. Parallel fetch() calls may share connections, open new ones, or ride multiplexed streams, so the number of promises tells you nothing about the number of connections.

Debugging Connection Issues

Force HTTP/1.1 when you test these headers; over HTTPS, curl will otherwise negotiate HTTP/2 if the server offers it:

curl --http1.1 -v https://example.com/ https://example.com/ -o /dev/null

Put both URLs in one curl command, as above. Separate curl invocations each start with an empty pool, so they can’t reuse anything. In the verbose output, look for curl’s messages about reusing or closing the connection; a Connection: keep-alive header on its own tells you little.

To see reuse from the server side, attach listeners once per connection. Add this to the Node server above, before listen():

let nextSocketId = 0
const socketIds = new WeakMap()
server.on('connection', (socket) => {
  const id = ++nextSocketId
  socketIds.set(socket, id)
  console.log('opened', id)
  socket.on('close', () => console.log('closed', id))
})
server.on('request', (req) => {
  console.log('request', socketIds.get(req.socket), req.method, req.url)
})

When the same ID shows up on several requests, this Node process served them over one socket. Behind nginx, that’s reuse between nginx and Node; browser-to-nginx reuse is a separate connection you’d check at nginx. Logging remotePort alone is weaker, since ports get reused across restarts. The socket comes from Node’s connection event.

If a connection closes after every request, check the response framing before blaming configuration. An HTTP/1.1 response with neither Content-Length nor chunked encoding may mark the end of its body by closing the connection, and then that socket can’t carry another response. HEAD responses, 204 and 304 follow different body rules. RFC 9112 §6.3 sets out the order of precedence.

To see headers and bytes together on your own service:

curl --http1.1 --trace-ascii - http://127.0.0.1:3000/ -o /dev/null

Trace output includes credentials and body data, so run it against an unauthenticated local request. On the client side, make sure your code reads the whole response body. A connection only becomes available for the next request once the current body has been consumed.

Testing

Send the same request with an explicit close and compare the verbose output:

curl --http1.1 -v -H 'Connection: close' https://example.com/ -o /dev/null

HTTP/2 and the Connection Header

In HTTP/2 and HTTP/3, a message containing Connection is malformed (RFC 9113 §8.2.2, RFC 9114 §4.2). Strip it when you translate HTTP/1.1 traffic to either protocol. If you don’t, clients reject the response; in Chrome it shows up as ERR_HTTP2_PROTOCOL_ERROR.

Frequently asked questions

What is the Connection header?

Connection carries options for the immediate HTTP/1.x connection, including close, and names hop-by-hop fields that intermediaries must remove.

Is Connection: keep-alive still needed?

HTTP/1.1 connections persist by default without this header. Connection is forbidden in HTTP/2 and HTTP/3; a message containing it is malformed.

When should I use Connection: close?

Send it on HTTP/1.1 when you plan to close the TCP connection after the current response, for example while a server drains before shutdown. It only affects the connection; a user’s login session is unrelated.

What is a hop-by-hop header?

A hop-by-hop header applies only to one connection, between two adjacent peers, not to the whole path. Connection is one, and so is every header it lists; a proxy strips them all before forwarding.

Sources

  1. MDN Web Docs: Connectiondeveloper.mozilla.org
  2. RFC 9110: Connectionrfc-editor.org

Keep going

Browse /search