Request and response header
< Connection: keep-aliveConnection Header
Learn how the Connection header controls whether HTTP connections stay open (keep-alive) or close after each request. Optimize with persistent connections.
- 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:
Connectioncontrols the current HTTP/1.x connection. HTTP/1.1 keeps connections open by default, so you rarely needConnection: keep-alive; sendConnection: closewhen 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
| Protocol | Connection handling |
|---|---|
| HTTP/1.0 | Close by default; historical keep-alive extension is implementation-dependent. |
| HTTP/1.1 | Persistent by default; close ends reuse after the response. |
| HTTP/2 and HTTP/3 | Connection 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.
Related Headers
- Keep-Alive
- Upgrade
- Transfer-Encoding — chunked framing can permit HTTP/1.1 reuse without Content-Length.
- Content-Length
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
Related
Accept Header
Learn how the Accept header tells servers which content types (JSON, HTML, XML) your client can handle. Master content negotiation and quality values.
Accept-Language Header
Learn how the Accept-Language header tells servers which languages your client prefers for localized content. Understand language tags and quality values.
Content-Disposition Header
Learn how the Content-Disposition header controls whether content displays inline or downloads as an attachment. Set custom filenames for file downloads.
Content-Language Header
Learn how the Content-Language header specifies the natural language(s) of response content. Understand language tags and internationalization best practices.