How HTTP Works

Request and response header

< Upgrade: websocket

Upgrade Header

Learn how the Upgrade header requests protocol upgrades to WebSocket, HTTP/2, or other protocols on the same TCP connection. Understand upgrade negotiation.

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

TL;DR: Upgrade requests an HTTP/1.1 protocol switch on the same connection. WebSocket uses Upgrade: websocket plus Connection: Upgrade and a validated 101 response. The old h2c upgrade to HTTP/2 is obsolete in RFC 9113.

A client offers one or more protocols; the server may accept with 101 Switching Protocols or continue responding in HTTP. Upgrade applies only to the immediate hop. RFC 9110 §7.8 requires its sender to name it in Connection.

How Upgrade Works

This illustrative WebSocket exchange uses the test key and matching accept value from RFC 6455 §1.3:

GET /chat HTTP/1.1
Host: example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

After the blank line ending the response headers, traffic uses WebSocket frames, not an HTTP response body. An HTTP 200 response does not establish this WebSocket connection.

Syntax

Upgrade: protocol-name[/protocol-version], another-protocol

Protocol names and versions come from the chosen protocol specification; arbitrary strings do not make a server support a new protocol.

WebSocket Upgrade

The server must validate the opening handshake, including a key that decodes to 16 bytes and version 13. Browser requests include Origin, which a server can check against permitted page origins. If a server selects a subprotocol, it must be one the client offered. See RFC 6455 §4.2.

HTTP/2 Upgrade

RFC 7540 described Upgrade: h2c and HTTP2-Settings. RFC 9113 §3.1 removes that mechanism; §§11.1–11.2 mark the header and token obsolete. Current HTTP/2 uses TLS ALPN or prior knowledge for cleartext connections.

Server Implementation

For nginx proxying to an HTTP/1.1 WebSocket server at 127.0.0.1:3000, place this in an existing server block:

location /chat {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

nginx does not forward hop-by-hop headers automatically. Its WebSocket proxying documentation explains why they must be supplied explicitly. By default, nginx closes this proxied connection if the upstream sends no data for 60 seconds; proxy_read_timeout changes that wait, and server ping frames can keep traffic flowing.

Node.js with ws

For a working local echo endpoint, install the protocol library with pnpm add ws. Save as echo.mjs and run node echo.mjs:

import http from 'node:http'
import { WebSocketServer } from 'ws'

const server = http.createServer((req, res) => {
  res.writeHead(404)
  res.end()
})
const wss = new WebSocketServer({ noServer: true })
server.on('upgrade', (req, socket, head) => {
  const path = new URL(req.url, 'http://localhost').pathname
  if (path !== '/chat') {
    socket.end('HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
    return
  }
  const origin = req.headers.origin
  if (origin && origin !== 'http://localhost:3000') {
    socket.end('HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
    return
  }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req))
})
wss.on('connection', (ws) => {
  ws.on('error', console.error)
  ws.on('message', (data, isBinary) => ws.send(data, { binary: isBinary }))
})
server.listen(3000, '127.0.0.1')

handleUpgrade() validates the handshake and retains bytes already read in head. The library also handles masking and frame parsing. The endpoint allows a missing Origin for non-browser diagnostic clients; that policy is not authentication. For a private endpoint, verify the session before calling handleUpgrade(). See ws’s upgrade and authentication examples.

Go with gorilla/websocket

In a Go module, install github.com/gorilla/websocket with go get github.com/gorilla/websocket. This alternate local server keeps the Upgrader’s default origin check:

package main

import (
    "log"
    "net/http"
    "github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{}

func main() {
    http.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
        conn, err := upgrader.Upgrade(w, r, nil)
        if err != nil { return }
        defer conn.Close()
        for {
            kind, data, err := conn.ReadMessage()
            if err != nil { return }
            if err := conn.WriteMessage(kind, data); err != nil { return }
        }
    })
    log.Fatal(http.ListenAndServe("127.0.0.1:3000", nil))
}

When Origin is present, the default policy rejects an Origin host different from the request Host. If a frontend on another hostname needs access, define a narrow CheckOrigin policy for that hostname. Returning true for every request removes the check.

Python with aiohttp

Install aiohttp in your Python environment and save this alternative echo server as echo.py. It handles both text and binary messages:

from aiohttp import web, WSMsgType

async def chat(request):
    origin = request.headers.get('Origin')
    if origin and origin != 'http://localhost:3000':
        raise web.HTTPForbidden()
    socket = web.WebSocketResponse()
    await socket.prepare(request)
    async for message in socket:
        if message.type == WSMsgType.TEXT:
            await socket.send_str(message.data)
        elif message.type == WSMsgType.BINARY:
            await socket.send_bytes(message.data)
        elif message.type == WSMsgType.ERROR:
            print(socket.exception())
    return socket

app = web.Application()
app.router.add_get('/chat', chat)
web.run_app(app, host='127.0.0.1', port=3000)

Run python echo.py instead of the Node or Go server. prepare() performs the opening handshake; after it succeeds, use WebSocket send methods rather than writing an HTTP response body. The aiohttp server guide documents this lifecycle. WSMsgType belongs to aiohttp, not aiohttp.web.

Caddy reverse proxy

Caddy handles WebSocket upgrades through its ordinary reverse proxy. For a local test, save this as Caddyfile:

:8080 {
    reverse_proxy 127.0.0.1:3000
}

Run caddy run --config Caddyfile and connect the client to ws://localhost:8080/chat. The backend still receives /chat; no path rewrite is configured. Unlike the nginx example, this does not require manually supplying Upgrade and Connection. Caddy’s reverse proxy documentation describes the switch to a bidirectional tunnel. A browser request still carries its page Origin, so account for that origin in the backend’s allowlist.

Client Implementation

In a browser, use the WebSocket API; it generates the handshake:

const socket = new WebSocket('wss://example.com/chat')
socket.addEventListener('open', () => socket.send('hello'))
socket.addEventListener('message', (event) => console.log(event.data))

The constructor accepts a URL and optional subprotocols, not arbitrary request headers. See the WebSockets Standard.

For the local echo server, a Node client can send headers that the browser constructor cannot. Save this as client.mjs after installing ws:

import WebSocket from 'ws'

const socket = new WebSocket('ws://127.0.0.1:3000/chat')
socket.on('error', console.error)
socket.on('open', () => socket.send('diagnostic echo'))
socket.on('message', (data) => {
  console.log(data.toString())
  socket.close()
})

A Python client provides another independent check. Install websockets in your Python environment, then run:

import asyncio
from websockets.asyncio.client import connect

async def main():
    async with connect('ws://127.0.0.1:3000/chat') as socket:
        await socket.send('diagnostic echo')
        print(await socket.recv())

asyncio.run(main())

The async context manager closes the connection on exit. If these clients work but the browser does not, compare its Origin and offered subprotocols with server policy. An Origin check can distinguish permitted browser pages; non-browser clients can supply that field themselves.

Security Considerations

An accept hash confirms handshake processing, not user authentication. Authorize access before accepting the connection, and use wss:// for TLS. Do not copy a server example that unconditionally accepts every Origin if access depends on browser cookies.

For ws, ping/pong can detect a connection that disappeared without a close frame. Add this inside the connection handler; the 30-second interval is a chosen application setting:

let alive = true
ws.on('pong', () => { alive = true })
const heartbeat = setInterval(() => {
  if (!alive) {
    ws.terminate()
    return
  }
  alive = false
  ws.ping()
}, 30000)
ws.on('close', () => clearInterval(heartbeat))

The ws heartbeat example uses this pattern. Align the interval with the proxy’s no-data timeout. A client ping alone may not satisfy a timer that specifically waits for data from the upstream server; make sure server responses or server pings traverse that leg.

Getting Upgrade right

Parse Connection as comma-separated tokens rather than checking whether a string contains the substring upgrade. notupgrade is not the Upgrade token. For a Node upgrade handler, this checks the token list:

const hasUpgradeToken = (req.headers.connection ?? '')
  .split(',')
  .some((token) => token.trim().toLowerCase() === 'upgrade')

Reject a missing token before switching protocols. This checks one field only; let the WebSocket library validate the remaining handshake, including the key and version. A substring check can accept notupgrade and conceal a malformed request.

Common Issues

Upgrade: WebSocket is not inherently wrong: RFC 6455 matches this value case-insensitively. Missing companion headers, a stripped key, or a normal HTTP response are reasons to inspect both sides of the proxy.

To inspect a handshake against your own local endpoint:

curl --http1.1 -i -N --max-time 5 \
  -H 'Connection: Upgrade' \
  -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' \
  -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  http://localhost:3000/chat

This uses a fixed diagnostic key, not a production client’s random key. The bounded command inspects the response; it is not an interactive WebSocket application.

HTTP/2 Considerations

Do not forward Connection or Upgrade into HTTP/2 or HTTP/3 messages. WebSocket over HTTP/2 uses extended CONNECT, defined separately in RFC 8441, rather than this HTTP/1.1 exchange.

Frequently asked questions

What is the Upgrade header?

Upgrade requests switching to a different protocol on the same connection. Most commonly used to upgrade HTTP to WebSocket. Server responds with 101 if it agrees.

How does WebSocket upgrade work?

For HTTP/1.1, the client sends Upgrade: websocket, Connection: Upgrade, and the WebSocket handshake fields. The server must return a valid 101 response with a matching Sec-WebSocket-Accept.

Can Upgrade be used for HTTP/2?

The historical h2c upgrade mechanism is obsolete in RFC 9113. Current HTTP/2 starts through TLS ALPN or prior knowledge on cleartext connections.

What happens if server ignores Upgrade?

Server can ignore Upgrade and respond normally with HTTP. The client should handle both upgraded and non-upgraded responses gracefully.

Sources

  1. MDN Web Docs: Upgradedeveloper.mozilla.org
  2. RFC 9110: Upgraderfc-editor.org

Keep going

Browse /search