iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
☎️

Reviewing HTTP Step-by-Step: Analyzing HTTP/1.1 to HTTP/3 with Wireshark

に公開

Objective

Many people implement programs using HTTP for frontend and backend development of web applications. However, we rarely have the opportunity to consciously observe how that data actually flows. The objective of this article is to confirm how transmitted and received data changes across HTTP/1.1, HTTP/2, and HTTP/3. We will use Wireshark to observe the data flow in the following cases:

  • Data between browser and server via HTTP/1.1
  • Data between browser and server via TLS1.3 + HTTP/1.1
  • Data between browser and server via HTTP/2
  • Data between browser and server via HTTP/3

In this article, verification of retransmissions and errors is out of scope; we will limit ourselves to confirming the differences in simple data transmission and reception for each case. While we will document the experimental methods and results, the results will not necessarily be identical depending on the environment and versions used.

This article is intended for the following readers:

  • People who are "somehow building" web application frontends and backends but want to verify what kind of data is actually flowing.
  • People who want to confirm the changes in HTTP/2 and HTTP/3 while looking at actual data.

We assume the reader has the following level of knowledge:

  • Basic understanding of HTTP (requests/responses, headers, etc.)
  • Experience using Wireshark
  • Experience writing simple sample code in any programming language (Node.js and Go are preferred)

Experimental Frontend Code

We will use the following HTML and JavaScript to verify communication between the browser and the server. The JavaScript executes five REST API requests to the server. We will use Chrome 143.0.7499.41 as the browser.

Experimental Code

index.html

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>Axios keep-alive / Connection Verification</title>
  </head>
  <body>
    <h1>Axios + Browser Connection Reuse Verification</h1>
    <button id="button-seq">5 requests in sequence</button>
    <button id="button-par">5 requests in parallel</button>

    <pre id="log" style="border:1px solid #ccc; padding:8px; max-height:400px; overflow:auto;"></pre>

    <!-- axios CDN (for convenience) -->
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="/client.js"></script>
  </body>
</html>

client.js

// client.js
const logEl = document.getElementById("log");
const btnSeq = document.getElementById("button-seq");
const btnPar = document.getElementById("button-par");

function log(msg) {
  console.log(msg);
  logEl.textContent += msg + "\n";
  logEl.scrollTop = logEl.scrollHeight;
}

// axios instance (adjust settings here if needed)
const api = axios.create({
  baseURL: "/",
  // withCredentials: false,
  // Browser-based axios cannot directly control keep-alive (leaves it to the browser)
});

async function callOnce(i) {
  const res = await api.get(`/api/test?i=${i}`);
  const data = res.data;
  log(
    `i=${i} -> ${JSON.stringify(data)}`
  );
}

// Call 5 times in sequence (serial)
btnSeq.addEventListener("click", async () => {
  log("---- 5 requests in sequence ----");
  for (let i = 0; i < 5; i++) {
    await callOnce(i);
  }
});

// Call 5 times in parallel
btnPar.addEventListener("click", async () => {
  log("---- 5 requests in parallel ----");
  await Promise.all(
    Array.from({ length: 5 }, (_, i) => {
      return callOnce(i);
    })
  );
});

Verifying HTTP/1.1

We will verify the data flow when performing HTTP/1.1 communication in plaintext.

For details on HTTP/1.1 message syntax, message parsing, and connection management, please refer to the following:
RFC 9112 – HTTP/1.1

Creating a Server for HTTP/1.1 Experiments

After setting up the Node.js environment, run the following server.

Server Code for HTTP/1.1

The API response introduces random delays.

// http1-server.js
'use strict';
const fs = require('fs');
const http = require('http');
const path = require('path');
let socketCounter = 0;
let requestCounter = 0;
const server = http.createServer((req, res) => {
  console.log('--- HTTP/1.1 request ---');
  console.log('url        :', req.url);
  console.log('method     :', req.method);
  console.log('httpVersion:', req.httpVersion);
  console.log('headers    :', req.headers);

  // Returning static files (for browser)
  if (req.url === "/" || req.url === "/index.html") {
    const html = fs.readFileSync(path.join(__dirname, "index.html"), "utf8");
    res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    res.end(html);
    return;
  }

  if (req.url.startsWith("/client.js")) {
    const js = fs.readFileSync(path.join(__dirname, "client.js"), "utf8");
    res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
    res.end(js);
    return;
  }
  // API section (called by axios)
  if (req.url.startsWith("/api")) {
    requestCounter++;
    const thisReqId = requestCounter;
    const socketId = req.socket.__id; // ID assigned in connection event

    console.log(
      `REQ#${thisReqId} on SOCKET#${socketId} ${req.method} ${req.url}`
    );

    // Intentionally delaying to make parallel request behavior clear
    const delay = 200 + Math.random() * 800;

    setTimeout(() => {
      const body = JSON.stringify({
        requestId: thisReqId,
        socketId,
        url: req.url,
        connectionHeader: req.headers["connection"] || null,
        now: new Date().toISOString(),
      });

      res.writeHead(200, {
        "Content-Type": "application/json; charset=utf-8",
        "Content-Length": Buffer.byteLength(body),
      });
      res.end(body);
    }, delay);
    return;
  }

  // 404 for others
  res.writeHead(404);
  res.end("Not Found");
});
// Called for each new TCP connection
server.on("connection", (socket) => {
  socketCounter++;
  socket.__id = socketCounter;

  console.log(
    `NEW SOCKET#${socket.__id} from ${socket.remoteAddress}:${socket.remotePort}`
  );

  socket.on("end", () => {
    console.log(`SOCKET#${socket.__id} end`);
  });

  socket.on("close", (hadError) => {
    console.log(`SOCKET#${socket.__id} closed`);
  });

  socket.on("error", (err) => {
    console.log(`SOCKET#${socket.__id} error`, err);
  });
});
const PORT = 8080;
server.listen(PORT, () => {
  console.log(`HTTP/1.1 server listening on http://localhost:${PORT}`);
});

Experiment

The experimental procedure is as follows:

  1. Start the HTTP/1.1 server code
node http1-server.js
  1. Launch Wireshark and capture Loopback: lo0
    Wireshark Screenshot
  2. Apply filter tcp.port == 8080 || udp.port == 8080
    Wireshark filter
  3. Launch Chrome
  4. Open Developer Tools Network tab. Right-click headers to show Protocol
    Chrome DevTools
  5. Access http://localhost:8080/index.html
  6. Press the "5 requests in parallel" button

Chrome DevTools Result


We can confirm that the protocol for communication with localhost is http/1.1.
Note that axios.min.js will show as h2—that is, HTTP/2—due to communication with cdn.jsdelivr.net.

Wireshark Result



Since there is a lot of TCP-level traffic, let's use the filter (tcp.port == 8080 || udp.port == 8080) && http to confirm.

This shows that multiple ports are being used:

  • 56635: GET /index.html, /client.js, /favicon.ico
  • 56636: GET /.well-known/appspecific/com.chrome.devtools.json
  • 56740: GET /api/test?i=0 HTTP/1.1
  • 56741: GET /api/test?i=1 HTTP/1.1
  • 56742: GET /api/test?i=2 HTTP/1.1
  • 56743: GET /api/test?i=3 HTTP/1.1
  • 56744: GET /api/test?i=4 HTTP/1.1

In other words, when connected via HTTP/1.1, the browser creates multiple sockets to perform communication.

HTTP/1.1 Sockets from browser

In the case of Chrome, up to 6 connections per host are made.
While it appears there are seven connections in the example above, 56635 and 56636 finish their communication before 56740 is connected.

Next, let's examine the details of each socket. Here, we look at the interaction for the GET /api/test?i=0 request performed on port 56740.

This flow can be summarized as follows:
Communication within HTTP/1.1 socket

First, we can confirm the following messages are flowing to establish the TCP socket:

  • [SYN] from client
  • [SYN, ACK] from server
  • [ACK] from client

This is known as the three-way (or three message) handshake (3WHS).[1]
After the connection is established, the server side notifies its receive window with [TCP Window Update]+[ACK].[2]

Finally, the browser sends the GET /api/test?i=0 request as text.

Upon receiving the request, the server sends an ACK to acknowledge it.

Once the server's processing is complete, it returns a response to the client. We can confirm this is also transmitted as text over TCP.

When the client receives the response, it sends an ACK to acknowledge receipt.

After a while, the server sends [FIN, ACK] to terminate the socket.

Verifying TLS1.3 + HTTP/1.1

Before verifying HTTP/2 and HTTP/3, we will confirm what happens when data is encrypted using TLS 1.3. While HTTP/2 does not technically require encryption by specification, it is mandatory when experimenting from general browsers.[3] In this section, we will create a server using a self-signed certificate and inspect HTTPS messages with Wireshark.

Creating a TLS 1.3-Compatible Server

First, use OpenSSL to create the server's private key file and server certificate.

# Create server.key / server.crt in the current directory
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout server.key -out server.crt \
  -subj "/CN=localhost" -days 365

Next, output the SPKI Fingerprint (Base64(SHA-256(SPKI DER))) from the created server.crt.

openssl x509 -pubkey -noout -in server.crt \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary \
  | openssl enc -base64

By providing this Base64 string to the browser's startup option --ignore-certificate-errors-spki-list, testing servers with self-signed certificates becomes easier.

Next, use the created server.key and server.crt to create an HTTPS server in Node.js.

TLS 1.3-Compatible Server Code

Replace the http module with the https module.

// https1-server.js
'use strict';
const fs = require('fs');
const https = require('https');              // * http -> https
const path = require('path');

let socketCounter = 0;
let requestCounter = 0;

// * Load certificate and private key
const options = {
  key: fs.readFileSync(path.join(__dirname, 'server.key')),
  cert: fs.readFileSync(path.join(__dirname, 'server.crt')),
};

// Swap to createSecureServer, the rest remains the same
const server = https.createServer(options, (req, res) => {
  console.log('--- HTTPS (HTTP/1.1) request ---');
  console.log('url        :', req.url);
  console.log('method     :', req.method);
  console.log('httpVersion:', req.httpVersion);
  console.log('headers    :', req.headers);

  if (req.url === "/" || req.url === "/index.html") {
    const html = fs.readFileSync(path.join(__dirname, "index.html"), "utf8");
    res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    res.end(html);
    return;
  }

  if (req.url.startsWith("/client.js")) {
    const js = fs.readFileSync(path.join(__dirname, "client.js"), "utf8");
    res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
    res.end(js);
    return;
  }

  if (req.url.startsWith("/api")) {
    requestCounter++;
    const thisReqId = requestCounter;
    const socketId = req.socket.__id;

    console.log(
      `REQ#${thisReqId} on SOCKET#${socketId} ${req.method} ${req.url}`
    );

    const delay = 200 + Math.random() * 800;

    setTimeout(() => {
      const body = JSON.stringify({
        requestId: thisReqId,
        socketId,
        url: req.url,
        connectionHeader: req.headers["connection"] || null,
        now: new Date().toISOString(),
      });

      res.writeHead(200, {
        "Content-Type": "application/json; charset=utf-8",
        "Content-Length": Buffer.byteLength(body),
      });
      res.end(body);
    }, delay);
    return;
  }

  res.writeHead(404);
  res.end("Not Found");
});

// connection handler can be reused
server.on("connection", (socket) => {
  socketCounter++;
  socket.__id = socketCounter;

  console.log(
    `NEW SOCKET#${socket.__id} from ${socket.remoteAddress}:${socket.remotePort}`
  );

  socket.on("end", () => {
    console.log(`SOCKET#${socket.__id} end`);
  });

  socket.on("close", (hadError) => {
    console.log(`SOCKET#${socket.__id} closed`);
  });

  socket.on("error", (err) => {
    console.log(`SOCKET#${socket.__id} error`, err);
  });
});

const PORT = 8443;
server.listen(PORT, () => {
  console.log(`HTTPS (HTTP/1.1) server listening on https://localhost:${PORT}`);
});

How to View Encrypted Traffic in Wireshark

Browsers can output logs in the SSLKEYLOGFILE format, which Wireshark can reference to decrypt encrypted communications.

Browser Startup Method

First, set the environment variable SSLKEYLOGFILE to the path of the log file.

export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log

Next, launch Chrome with the SPKI Fingerprint specified earlier.

# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4= is the SPKI
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \
  --user-data-dir=/tmp/temp-chrome

After launching Chrome and browsing, entries like the following will be appended to tls_keys.log.

CLIENT_HANDSHAKE_TRAFFIC_SECRET ...
SERVER_HANDSHAKE_TRAFFIC_SECRET ...
... etc.

How to Configure Wireshark

Select Wireshark → Preferences in the menu.

Select Protocols from the tree on the left in the Preferences dialog.

After selecting TLS, specify tls_keys.log in the "(Pre-)Master-Secret log filename or RSA keys list" field.

From now on, performing captures in Wireshark will allow inspection of the otherwise encrypted HTTP traffic.

Experiment

The experimental procedure is as follows:

  1. Start the HTTPS server code:
    node https1-server.js
  2. Launch Wireshark and capture Loopback: lo0
  3. Apply filter tcp.port == 8443 || udp.port == 8443
  4. Start Chrome with the SSLKEYLOGFILE environment variable set:
    (Use the same startup command as above)
  5. Open Developer Tools Network tab.
  6. Access https://localhost:8443/index.html
  7. Press the "5 requests in parallel" button

Chrome DevTools Result

There is no difference in the result compared to HTTP/1.1 at the Network level in DevTools.

Wireshark Result

While the operation is identical, the traffic is more complex.



There is no difference in the number of ports used.

However, the volume of communication per port has increased. Here, we examine the exchange on ports 50918 and 50919.

This flow can be summarized as follows:

ClientHello from Browser

The three-way handshake (3WHS) and [TCP Window Update]+[ACK] sequence remain the same as in plaintext.

When connecting via HTTPS, the browser sends a ClientHello using the TLSv1.3 protocol.

In ClientHello, the client notifies the server of its communication preferences.
For example:

  • The key_share extension includes the client's public key for key exchange.
  • The ALPN extension suggests h2 (HTTP/2) and http/1.1 as supported application protocols.

ServerHello to Finished from Server

After receiving ClientHello, the server sends ServerHello, ChangeCipherSpec, EncryptedExtensions, Certificate, CertificateVerify, and Finished to the client.

Certificate and CertificateVerify may not be sent in subsequent communications.

ServerHello

ServerHello informs the client about the chosen configuration, such as the TLS version and the server's public key for key exchange.

ChangeCipherSpec

Per the TLS 1.3 specification, ChangeCipherSpec is meaningless and should be ignored.[4]

EncryptedExtensions

Server responses to ClientHello extensions (such as ALPN and SNI responses) are sent together in encrypted form.


In this example, it indicates that http/1.1 was chosen from the client's suggested h2 and http/1.1.

Certificate

Sends the server certificate.

CertificateVerify

The server sends signed data using its private key to prove it holds the corresponding private key for the certificate. The client verifies this against the public key in the server certificate to ensure no impersonation has occurred.

Finished

The server sends a Message Authentication Code (MAC) computed from the transcript hash of all handshake messages. The client performs the same calculation; if the values match, it confirms that the handshake has not been tampered with and both parties share the same keys.

ChangeCipherSpec and Finished from Browser


In TLS 1.3, ChangeCipherSpec has no meaning; the handshake is completed by Finished.

NewSessionTicket from Server

The server sends a NewSessionTicket to enable 0-RTT reconnection, allowing data transmission starting with zero round-trips in the future.

Post-Handshake HTTP Communication

Subsequently, request and response traffic flows via the HTTP protocol, identical to the HTTP/1.1 scenario mentioned earlier.

Verifying HTTP/2

While HTTP/1.1 essentially relied on multiple socket implementations in browsers[5], HTTP/2, as defined in RFC 9113 – HTTP/2, performs multiplexing, header compression, and priority control over a single socket.

The conceptual diagram for HTTP/2 data transmission is as follows:

Multiple streams exist within a single connection, and data is transmitted and received in units of data frames within those streams.

Note that even with HTTP/2, the Head-of-line blocking problem is not completely resolved.[6]

Creating an HTTP/2-Compatible Server

This can be implemented using the http2 module instead of the http module. In this implementation, we use Early Hints to preload client.js when loading index.html. Early Hints is a mechanism to start loading necessary resources (like JavaScript or CSS) before the main page response is ready. While theoretically available since the HTTP/1.1 era, its effect was limited due to the browser restriction of one request per connection.[5:1] In HTTP/2, since multiple streams can be handled concurrently within a single connection, the effect of preloading via Early Hints is highly effective.

HTTP/2 Server Example
// http2-server.js
'use strict';
const fs = require('fs');
const http2 = require('http2');             // * https -> http2
const path = require('path');

let socketCounter = 0;
let requestCounter = 0;
let sessionCounter = 0;

// * Load certificate and private key
const options = {
  key: fs.readFileSync(path.join(__dirname, 'server.key')),
  cert: fs.readFileSync(path.join(__dirname, 'server.crt')),
  // Allow HTTP/1.1 for non-HTTP/2 compatible clients (optional)
  allowHTTP1: true,
};

// Swap to createSecureServer (HTTP/2 compatible API), handlers remain mostly the same
const server = http2.createSecureServer(options, (req, res) => {
  console.log('--- HTTPS (HTTP/2) request ---');
  console.log('url        :', req.url);
  console.log('method     :', req.method);
  console.log('httpVersion:', req.httpVersion); // e.g., "2.0"
  console.log('headers    :', req.headers);

  if (req.url === "/" || req.url === "/index.html") {
    // * Example of sending Early Hints (103)
    //   - Meaningful mainly for HTTP/2 / HTTP/3
    //   - Preload client.js via Link header
    if (typeof res.writeEarlyHints === "function" && req.httpVersion.startsWith("2")) {
      const links = [
        "</client.js>; rel=preload; as=script",    // Preload JS
      ];

      console.log("sending 103 Early Hints");
      res.writeEarlyHints({
        link: links,
      });
    }
    setTimeout(() => {
      const html = fs.readFileSync(path.join(__dirname, "index.html"), "utf8");
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      res.end(html);
    }, 5000)
    return;
  }

  if (req.url.startsWith("/client.js")) {
    const js = fs.readFileSync(path.join(__dirname, "client.js"), "utf8");
    res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
    res.end(js);
    return;
  }

  if (req.url.startsWith("/api")) {
    requestCounter++;
    const thisReqId = requestCounter;
    const sessionId = req.stream.session.__id;
    console.log(
      `REQ#${thisReqId} on SESSION#${sessionId} ${req.method} ${req.url}`
    );

    const delay = 200 + Math.random() * 800;

    setTimeout(() => {
      const body = JSON.stringify({
        requestId: thisReqId,
        sessionId,
        url: req.url,
        connectionHeader: req.headers["connection"] || null,
        now: new Date().toISOString(),
      });

      res.writeHead(200, {
        "Content-Type": "application/json; charset=utf-8",
        "Content-Length": Buffer.byteLength(body),
      });
      res.end(body);
    }, delay);
    return;
  }

  res.writeHead(404);
  res.end("Not Found");
});

// * Event called for every HTTP/2 "session"
server.on('session', (session) => {
  sessionCounter++;
  session.__id = sessionCounter;
  console.log(`NEW SESSION#${session.__id}`);
});

// Connection handler can still be used (http2.Server inherits from tls.Server)
server.on("connection", (socket) => {
  socketCounter++;
  socket.__id = socketCounter;

  console.log(
    `NEW SOCKET#${socket.__id} from ${socket.remoteAddress}:${socket.remotePort}`
  );

  socket.on("end", () => {
    console.log(`SOCKET#${socket.__id} end`);
  });

  socket.on("close", (hadError) => {
    console.log(`SOCKET#${socket.__id} closed`);
  });

  socket.on("error", (err) => {
    console.log(`SOCKET#${socket.__id} error`, err);
  });
});

const PORT = 8443;
server.listen(PORT, () => {
  console.log(
    `HTTPS (HTTP/2) server listening on https://localhost:${PORT}`
  );
});

Experiment

The experimental procedure is as follows:

  1. Start the HTTP/2 server code:
    node http2-server.js
  2. Launch Wireshark and capture Loopback: lo0
  3. Apply filter tcp.port == 8443 || udp.port == 8443
  4. Start Chrome with the SSLKEYLOGFILE environment variable set:
    (Use the same startup command as the previous section)
  5. Open Developer Tools Network tab.
  6. Access https://localhost:8443/index.html
  7. Press the "5 requests in parallel" button

Chrome DevTools Result

In the Network tab of the Developer Tools, you can confirm that the protocol column displays "h2".

Wireshark Result



Filtering by http2 confirms that the number of ports used has decreased.

Most communications occur on port 49906, with port 49907 remaining almost idle. As mentioned, this confirms that transmission and reception are happening concurrently within a single connection.

Data flowing on port 49906 is summarized in the diagram below. Sn denotes the stream ID. In this example, operation occurs with up to 3 parallel streams (S0, S1, S3).

Connection Preface

A fixed 24-byte string is sent by the HTTP/2 client immediately after connection establishment.

SETTINGS Frame

Declarations from the receiver to control "how frames should be sent on this connection."[7] The receiver must respond by setting the ACK flag (0x01) in the Flags field.

In this example:

  • 627 Browser → Server SETTINGS
  • 635 Server → Browser SETTINGS
  • 637 Browser → Server SETTINGS(ACK)
  • 639 Server → Browser SETTINGS(ACK)

The content of the SETTINGS frame from the browser (627) is:

  • SETTINGS_HEADER_TABLE_SIZE: 65536
    • Max dynamic table size for HPACK (HTTP/2 header compression).
  • SETTINGS_ENABLE_PUSH: 0
    • Disable server push.
  • SETTINGS_INITIAL_WINDOW_SIZE: 6291456
    • HTTP/2 flow control window; declares the server may initially send ~6MB of DATA per stream.
  • SETTINGS_MAX_HEADER_LIST_SIZE
    • Max total size of headers allowed in a single request/response.

WINDOW_UPDATE Frame

It is confirmed that this is sent during the initial client transmission alongside the connection preface and SETTINGS frame.

This is a process to increase the flow control window, indicating the upper limit of data that can be received. The counter decreases as DATA frames are received and increases upon receiving WINDOW_UPDATE frames.

HEADERS Frame Compression

HTTP/2 compresses headers using HPACK, stored in the Header Block Fragment.

By analyzing the Header Block Fragment, you can see significant size differences between the initial and subsequent HEADERS frames, as compression utilizes data from previous HEADERS frames sent in the same direction (client → server).

PING Frame

Used to verify if the connection is functional. Upon receiving a PING frame, the receiver must reply with a PING frame with the ACK flag set.

PING from browser to server

Response to the above

Verifying HTTP/3

HTTP/3 utilizes the QUIC protocol over UDP instead of TCP to achieve HTTP/3 functionality.

This improves latency and resolves the Head-of-line blocking problem present in HTTP. Several papers compare the performance of HTTP/2 and HTTP/3.[8][9]

Since implementing QUIC or HTTP/3 communication programs in Node.js is difficult,[10] we will use Go for the necessary programming. If you prefer not to implement an HTTP server in Go, you could run a proxy server such as Caddy using H3 and maintain the actual processing in HTTP/1.1 or HTTP/2.

QUIC Verification

Before conducting the HTTP/3 experiment, we will create simple QUIC client and server implementations in Go to verify their communication content. A sample code snippet is provided below.

QUIC Sample Code

Server: quic_server.go

// quic_server.go
// TODO:
// go get github.com/quic-go/quic-go@latest
package main

import (
	"context"
	"crypto/tls"
	"log"
	"net"

	quic "github.com/quic-go/quic-go"
)

func main() {
	addr, err := net.ResolveUDPAddr("udp", "0.0.0.0:8443")
	if err != nil {
		log.Fatalf("ResolveUDPAddr: %v", err)
		return
	}
	udpConn, err := net.ListenUDP("udp", addr)
	if err != nil {
		log.Fatalf("ListenUDP: %v", err)
		return
	}
	defer udpConn.Close()

	tr := &quic.Transport{
		Conn: udpConn,
	}
	log.Printf("Transport: %v", tr)
	cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
	if err != nil {
		log.Fatalf("LoadX509KeyPair: %v", err)
	}

	tlsConfig := &tls.Config{
		Certificates: []tls.Certificate{cert},
		NextProtos:   []string{"quic-echo-example"},
		MinVersion:   tls.VersionTLS13,
		MaxVersion:   tls.VersionTLS13,
	}
	quicConf := &quic.Config{Allow0RTT: true}
	ln, err := tr.Listen(tlsConfig, quicConf)
	if err != nil {
		log.Fatalf("tr.Listen: %v", err)
		return
	}
	defer ln.Close()
	for {
		conn, err := ln.Accept(context.Background())
		if err != nil {
			log.Printf("Accept error: %v", err)
			continue
		}

		log.Printf("accepted QUIC conn from %v", conn.RemoteAddr())
		go handleConn(conn)
	}

}

func handleConn(conn *quic.Conn) {
	defer conn.CloseWithError(0, "bye")

	for {
		stream, err := conn.AcceptStream(context.Background())
		if err != nil {
			log.Printf("AcceptStream error from %v: %v", conn.RemoteAddr(), err)
			return
		}

		go handleStream(stream)
	}
}
func handleStream(stream *quic.Stream) {
	defer stream.Close()

	buf := make([]byte, 4096)
	n, err := stream.Read(buf)
	if err != nil {
		log.Printf("stream.Read error (ID=%d): %v", stream.StreamID(), err)
		return
	}

	msg := string(buf[:n])
	log.Printf("received on stream %d: %q", stream.StreamID(), msg)

	if _, err := stream.Write([]byte("echo: " + msg)); err != nil {
		log.Printf("stream.Write error (ID=%d): %v", stream.StreamID(), err)
		return
	}
	log.Printf("replied on stream %d", stream.StreamID())
}

Client: quic_client.go

// quic_client.go
// TODO:
// go get github.com/quic-go/quic-go@latest
package main

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"io"
	"log"
	"os"

	quic "github.com/quic-go/quic-go"
)

func main() {
	caCert, err := os.ReadFile("server.crt")
	if err != nil {
		log.Fatalf("read ca cert error: %v", err)
	}
	rootCAs := x509.NewCertPool()
	if !rootCAs.AppendCertsFromPEM(caCert) {
		log.Fatalf("failed to append ca cert")
	}

	keyLogFile, err := os.OpenFile(
		"/work/techblog/http2/tls_keys.log",
		os.O_CREATE|os.O_WRONLY|os.O_TRUNC,
		0600,
	)
	if err != nil {
		log.Fatalf("open key log file: %v", err)
	}
	defer keyLogFile.Close()

	tlsConf := &tls.Config{
		InsecureSkipVerify: true,
		RootCAs:            rootCAs,
		ServerName:         "localhost",
		NextProtos:         []string{"quic-echo-example"},
		MinVersion:         tls.VersionTLS13,
		MaxVersion:         tls.VersionTLS13,
		KeyLogWriter:       keyLogFile,
	}

	quicConf := &quic.Config{}

	ctx := context.Background()

	conn, err := quic.DialAddr(ctx, "127.0.0.1:8443", tlsConf, quicConf)
	if err != nil {
		log.Fatalf("DialAddr: %v", err)
	}
	defer conn.CloseWithError(0, "bye")

	log.Printf("connected: local=%v remote=%v", conn.LocalAddr(), conn.RemoteAddr())

	stream, err := conn.OpenStreamSync(ctx)
	if err != nil {
		log.Fatalf("OpenStreamSync: %v", err)
	}
	defer stream.Close()

	msg := "hello QUIC"
	if _, err := stream.Write([]byte(msg)); err != nil {
		log.Fatalf("stream.Write: %v", err)
	}
	log.Printf("sent: %q", msg)

	reply, err := io.ReadAll(stream)
	if err != nil {
		log.Fatalf("stream.Read (io.ReadAll): %v", err)
	}
	log.Printf("got reply: %q", string(reply))
	log.Printf("done")
}

The server waits for transmission from the client and echoes back the received data. The client simply sends "hello QUIC" to the server. By specifying the file stored in the SSLKEYLOGFILE environment variable as the KeyLogWriter, you can inspect the content of the communication between the client and server using Wireshark.

Experiment steps:

  1. Start the server: go run quic_server.go
  2. Start Wireshark and begin capture.
  3. Start the client: export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log && go run quic_client.go

Wireshark Results

Looking at this data, we can see that QUIC packets such as Initial, Handshake, etc., are transmitted via UDP. The QUIC packets contain frames like CRYPTO, PADDING, ACK, etc. Detailed specifications for these packets and frames are available in RFC 9000 and RFC 9001.

...

The flow of these packets can be summarized in the following diagram:

ClientHello from the client

The TLS handshake messages from the client are transmitted within an Initial packet composed of CRYPTO frames. Since the Initial packet transmitted from the client requires a payload of at least 1200 bytes, PADDING frames are used to fill the space as necessary.[11]

Below are the actual contents of the QUIC packet.


Combining all the CRYPTO frames transmitted this time results in the following Client Hello.

ServerHello through Finished from the server

The server transmits the ServerHello, EncryptedExtensions, Certificate, CertificateVerify, and Finished messages to perform the handshake.

The server received packet number 0 and packet number 1 due to the ClientHello from the client. First, it notifies the client that it has received packet number 0 using an ACK frame.

In cases containing only an ACK frame, it does not induce an ACK from the client, so it does not send PADDING frames.[12]

Next, it transmits an Initial packet containing the following frames:

  • An ACK frame for packet number 1.
  • A PADDING frame to ensure the UDP datagram payload is at least 1200 bytes when sending frames that induce an ACK.
  • The ServerHello via a CRYPTO frame.

After transmitting the ServerHello, it sends EncryptedExtensions, Certificate, CertificateVerify, and Finished via CRYPTO frames within a Handshake packet.

Transmission of the NEW_CONNECTION_ID frame

After the server has transmitted the handshake messages, it sends a NEW_CONNECTION_ID frame.

This ID is one that the client may use as the Destination Connection ID (DCID) in the future. The timing for switching to this ID is at the client's discretion. The ID 978b7549 appearing here can be confirmed being used as the DCID in subsequent transmissions from the client.

Client response to the handshake

Since the client received the Initial packet containing the ServerHello, it notifies the server of that packet number via an ACK frame.

The client transmits a Handshake packet (a reply to the server's handshake) and a Short Header packet.

  • Handshake packet
    • ACK frame: Indicates that the server's Handshake packet has been received.
    • CRYPTO frame: The TLS 1.3 Finished message from the client.
  • Short Header packet
    • RETIRE_CONNECTION_ID frame: Notifies that the old Connection ID is no longer needed because the client received the NEW_CONNECTION_ID frame and has started using a newer Connection ID.
New Session Ticket from the server

The server transmits a TLS 1.3 New Session Ticket.

  • Short Header packet
    • ACK frame: Notifies receipt of the packet containing the RETIRE_CONNECTION_ID frame.
    • CRYPTO frame: TLS 1.3 New Session Ticket.
    • HANDSHAKE_DONE frame: A frame sent by the server to notify the client that the handshake is complete.
    • NEW_TOKEN frame: Provides a token for accelerating future reconnections.
Client sending characters

The client sends the string "hello QUIC" to the server.

The ACK frame here notifies the server of the packet number containing the NEW_CONNECTION_ID frame received from the server.

The STREAM frame carries the string to be sent. The Stream Data in this instance is as follows:

Server Echo

When the server receives the characters from the client, it echoes the content back.

It notifies the client of the receipt of the packet containing the STREAM frame via an ACK frame. The echoed content is carried in the STREAM frame. The Stream Data in this instance is as follows:

Client termination process

The client notifies the server of the receipt of the packet containing the echo via an ACK frame.

Afterward, the client transmits a CONNECTION_CLOSE frame to terminate the communication.

Creating an HTTP/3 Server

In Go, it is possible to support HTTP/3 by using github.com/quic-go/quic-go/http3.

HTTP/3 Server Code

http3-server.go

// http3-server.go
// go mod init http3test
// go get github.com/quic-go/quic-go/http3@latest
// go run http3-server.go
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"math/rand"
	"net/http"
	"os"
	"path/filepath"
	"sync/atomic"
	"time"

	http3 "github.com/quic-go/quic-go/http3"
)

var requestCounter int64

func main() {
	rand.Seed(time.Now().UnixNano())

	mux := http.NewServeMux()
	mux.HandleFunc("/", handleRoot)
	mux.HandleFunc("/index.html", handleRoot)
	mux.HandleFunc("/client.js", handleClientJS)
	mux.HandleFunc("/api/", handleAPI)

	baseDir, err := filepath.Abs(".")
	if err != nil {
		log.Fatal(err)
	}
	log.Printf("baseDir: %s", baseDir)

	handler := loggingMiddleware(mux)

	log.Println("HTTP/1.1, HTTP/2, HTTP/3 server listening on https://localhost:8443")
	if err := http3.ListenAndServeTLS(":8443", "server.crt", "server.key", handler); err != nil {
		log.Fatal(err)
	}
}

func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Println("--- HTTPS request ---")
		log.Println("url        :", r.URL.String())
		log.Println("method     :", r.Method)
		log.Println("httpVersion:", r.Proto)
		log.Println("headers    :")
		for k, v := range r.Header {
			log.Printf("  %s: %v", k, v)
		}
		next.ServeHTTP(w, r)
	})
}

func handleRoot(w http.ResponseWriter, r *http.Request) {
	path := filepath.Join(".", "index.html")
	data, err := os.ReadFile(path)
	if err != nil {
		http.Error(w, "index.html not found", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(data)
}

func handleClientJS(w http.ResponseWriter, r *http.Request) {
	path := filepath.Join(".", "client.js")
	data, err := os.ReadFile(path)
	if err != nil {
		http.Error(w, "client.js not found", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(data)
}

func handleAPI(w http.ResponseWriter, r *http.Request) {
	reqID := atomic.AddInt64(&requestCounter, 1)

	delayMs := 200 + rand.Intn(801)
	time.Sleep(time.Duration(delayMs) * time.Millisecond)

	bodyStruct := map[string]interface{}{
		"requestId":        reqID,
		"url":              r.URL.String(),
		"connectionHeader": firstOrNil(r.Header["Connection"]),
		"now":              time.Now().UTC().Format(time.RFC3339Nano),
	}

	body, err := json.Marshal(bodyStruct)
	if err != nil {
		http.Error(w, "json encode error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(body)
}

func firstOrNil(v []string) interface{} {
	if len(v) == 0 {
		return nil
	}
	return v[0]
}

Accessing the HTTP/3 server with self-signed certificates in Chrome

When accessing an HTTP/3 server with self-signed certificates in Chrome, you must specify the following options:

  • --ignore-certificate-errors-spki-list: The SPKI Fingerprint output from server.crt
  • --origin-to-force-quic-on: A development flag that forces the browser to "preferentially attempt" QUIC (HTTP/3) connections for the specified origin, even if the prerequisite conditions are not met.
# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4= is the SPKI
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --origin-to-force-quic-on=localhost:8443 \
  --ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \
  --user-data-dir=/tmp/temp-chrome

Reference:
Not being able to skip checking localhost certificates for QUIC/WebTransport

Experiment

The experiment steps are as follows:

  1. Start the HTTP/3 server:
go run http3-server.go
  1. Start Wireshark and begin capturing Loopback: lo0.
  2. Filter by tcp.port == 8443 || udp.port == 8443.
  3. Set SSLKEYLOGFILE as an environment variable and launch Chrome with the --origin-to-force-quic option.
export SSLKEYLOGFILE=/work/techblog/http2/tls_keys.log
# 5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4= is the SPKI
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --origin-to-force-quic-on=localhost:8443 \
  --ignore-certificate-errors-spki-list="5ZXuDPc8AV/4PslNz7Th+6ocVtv7Zya2cy5HMSwBaA4=" \
  --user-data-dir=/tmp/temp-chrome
  1. Open the Network tab in DevTools, right-click the headers and display Protocol.
  2. Access https://localhost:8443/index.html.
  3. Click the "5 requests in parallel" button.

See RFC 9114 HTTP/3 for the HTTP/3 protocol.

Chrome DevTools Results

Checking the Network tab in DevTools confirms that the protocol is displayed as "h3".

Wireshark Results

Checking Wireshark confirms that only QUIC and HTTP/3 protocols exist (no TCP).


Filtering by http3 confirms that only one port is used, similar to HTTP/2.

Unknown Frames

Looking closely at the QUIC protocol, some frames exist that were not present in the simple QUIC client/server results from earlier.

  • PING frame
  • MAX_STREAMS (BIDI) frame
PING frame

We can see multiple PING frames in the Initial packet from the client. This is because there is an implementation that intentionally mixes PING/PADDING randomly into the Initial packet for future extensions.[13]

For details on how PING or PADDING frames are inserted into the Initial packet, refer to the following implementation:
quic/core/quic_chaos_protector.cc

MAX_STREAMS frame

A frame used to notify the peer of the maximum number of bidirectional streams that can be opened.

HTTP/3 SETTINGS frame

A setting declaration from the receiver to control how the other party sends frames over this connection. Both the client and the server transmit HTTP/3 SETTINGS frames.

Client to Server
The client declares the settings it can receive.

Server to Client
The server declares the settings it can receive.

Request Processing

The packet where the client requests index.html is as follows:

The PRIORITY_UPDATE frame is used to update the stream priority. The HEADERS frame sets the request headers to fetch GET /index.html.

Response Processing

The packet where the server returns index.html is as follows:

The HEADERS frame and DATA frame are transmitted to the client on the same stream.

Conclusion

In this article, we used Wireshark to verify the data flow for simple communications across HTTP/1.1 through HTTP/3. While you do not need this information to simply build web applications, I hope this helped you grasp the fundamental characteristics of each protocol.

References

脚注
  1. RFC 9293 Transmission Control Protocol (TCP) 3.4.1. Initial Sequence Number Selection ↩︎

  2. RFC 9293 Transmission Control Protocol (TCP) 3.8.6.2.2. Receiver's Algorithm -- When to Send a Window Update ↩︎

  3. Does HTTP/2 require encryption? states currently no browser supports HTTP/2 unencrypted. ↩︎

  4. RFC 8446 The Transport Layer Security (TLS) Protocol Version 1.3 D.4: Either side can send change_cipher_spec at any time during the handshake, as they must be ignored by the peer, but if the client sends a non-empty session ID, the server MUST send the change_cipher_spec as described in this appendix. ↩︎

  5. HTTP/1.1 could also multiplex on a single socket using keep-alive + pipelining. However, Firefox and Chrome have deprecated pipelining. ↩︎ ↩︎

  6. While HTTP/2 can transmit multiple streams over a single socket, the entire connection can be affected by the Head-of-line blocking problem during packet loss. Domain-Sharding for Faster HTTP/2 in Lossy Cellular Networks discusses performance degradation in environments with high packet loss. ↩︎

  7. RFC 9113 – HTTP/2 6.5.2. Defined Settings defines the items that can be configured. ↩︎

  8. Performance Evaluation of HTTP/2 and HTTP/3(QUIC) using Lighthouse Notably, HTTP/3 consistently surpasses HTTP/2 across all metrics except for Throughput where HTTP/2 gained little advantage. The result reveals HTTP/3’s superior capability to manage network instabilities and packet loss. ↩︎

  9. Performance Comparison of HTTP/3 and HTTP/2 with Proxy Integration While optimized H2 can match H3 in some settings, H3 is more robust overall, showing less sensitivity to proxies, impairments, and congestion control variations. ↩︎

  10. Update on QUIC: Discusses the difficulties of using QUIC in Node.js as of 2025. ↩︎

  11. RFC 9000 QUIC: A UDP-Based Multiplexed and Secure Transport 8.1 Clients MUST ensure that UDP datagrams containing Initial packets have UDP payloads of at least 1200 bytes, adding PADDING frames as necessary. ↩︎

  12. RFC 9000 QUIC: A UDP-Based Multiplexed and Secure Transport 14.1 Similarly, a server MUST expand the payload of all UDP datagrams carrying ack-eliciting Initial packets to at least the smallest allowed maximum datagram size of 1200 bytes ↩︎

  13. Fragmented CRYPTO frames in payload? ↩︎

Discussion