HTTP/2: Multiplexing, Server Push, and Header Compression
HTTP/2 represents the most significant upgrade to the HTTP protocol since HTTP/1.1 was standardized in 1997. While HTTP/1.1 served the web well for nearly two decades, modern applications with...
Key Insights
- HTTP/2’s multiplexing eliminates head-of-line blocking by allowing multiple requests over a single TCP connection, dramatically reducing latency for modern web applications with dozens of assets.
- Server push can improve initial page load times by proactively sending resources, but requires careful cache management to avoid pushing resources the client already has.
- HPACK header compression reduces bandwidth overhead by 30-80% in typical scenarios, particularly beneficial for applications with large cookies or authorization headers.
HTTP/2 represents the most significant upgrade to the HTTP protocol since HTTP/1.1 was standardized in 1997. While HTTP/1.1 served the web well for nearly two decades, modern applications with hundreds of assets, API-heavy architectures, and real-time requirements exposed its fundamental limitations. HTTP/2 addresses these issues through three core features: multiplexing, server push, and header compression.
Introduction to HTTP/2
HTTP/1.1’s primary bottleneck is connection overhead. Browsers limit concurrent connections per domain (typically 6-8), forcing requests to queue. Developers worked around this with domain sharding, asset concatenation, and sprite sheets—hacks that added complexity without solving the underlying problem.
HTTP/2 changes the game by allowing multiple requests and responses to be in flight simultaneously over a single connection. This eliminates head-of-line blocking at the HTTP layer and makes many HTTP/1.1 optimization techniques obsolete or even counterproductive.
Node.js has supported HTTP/2 natively since version 8.4.0. All modern browsers support it, but only over TLS (HTTPS). Here’s a basic comparison:
// HTTP/1.1 server
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('HTTP/1.1 response');
}).listen(8080);
// HTTP/2 server
const http2 = require('http2');
const fs = require('fs');
const server = http2.createSecureServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt')
});
server.on('stream', (stream, headers) => {
stream.respond({
'content-type': 'text/plain',
':status': 200
});
stream.end('HTTP/2 response');
});
server.listen(8443);
The HTTP/2 API differs significantly—you work with streams rather than request/response objects. This reflects the protocol’s fundamental shift in how data flows.
Multiplexing: Multiple Requests Over a Single Connection
Multiplexing is HTTP/2’s killer feature. Instead of opening multiple TCP connections, HTTP/2 breaks down requests and responses into frames that are interleaved over a single connection. Each request/response pair operates on a stream, identified by a unique stream ID.
This architecture eliminates HTTP-level head-of-line blocking. If one resource takes time to generate (say, a slow database query), other resources can continue transferring without waiting. The browser receives frames from multiple streams and reassembles them.
Here’s a practical example showing concurrent handling:
const http2 = require('http2');
const fs = require('fs');
const server = http2.createSecureServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt')
});
server.on('stream', (stream, headers) => {
const path = headers[':path'];
// Simulate different response times
const delay = path.includes('slow') ? 2000 : 100;
setTimeout(() => {
stream.respond({
'content-type': 'application/json',
':status': 200
});
stream.end(JSON.stringify({
path,
timestamp: Date.now()
}));
}, delay);
});
server.listen(8443);
On the client side, you can fire off multiple requests simultaneously:
// Client-side code
async function loadResources() {
const urls = [
'/api/fast',
'/api/slow',
'/api/fast2',
'/api/fast3'
];
const startTime = Date.now();
// All requests go over the same connection
const responses = await Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);
console.log(`Loaded ${urls.length} resources in ${Date.now() - startTime}ms`);
console.log(responses);
}
With HTTP/1.1, the slow request would block others in the queue. With HTTP/2, fast requests complete independently. In real applications with dozens of assets, this translates to significantly faster page loads.
Server Push: Proactive Resource Delivery
Server push allows the server to send resources before the client requests them. When serving HTML, you can push associated CSS and JavaScript files, eliminating round trips.
However, server push is nuanced. Pushing resources the client already has cached wastes bandwidth. The browser can send a RST_STREAM frame to cancel unwanted pushes, but the damage may already be done on slow connections.
Here’s how to implement server push:
const http2 = require('http2');
const fs = require('fs');
const path = require('path');
const server = http2.createSecureServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt')
});
server.on('stream', (stream, headers) => {
const reqPath = headers[':path'];
if (reqPath === '/') {
// Push critical CSS and JS before sending HTML
const cssPush = stream.pushStream({ ':path': '/styles.css' }, (err, pushStream) => {
if (err) throw err;
pushStream.respond({
'content-type': 'text/css',
':status': 200
});
pushStream.end(fs.readFileSync('./styles.css'));
});
stream.pushStream({ ':path': '/app.js' }, (err, pushStream) => {
if (err) throw err;
pushStream.respond({
'content-type': 'application/javascript',
':status': 200
});
pushStream.end(fs.readFileSync('./app.js'));
});
// Send HTML
stream.respond({
'content-type': 'text/html',
':status': 200
});
stream.end('<html><head><link rel="stylesheet" href="/styles.css"></head><body><script src="/app.js"></script></body></html>');
}
});
server.listen(8443);
In the browser, you can detect pushed resources:
// Check if a resource was pushed
performance.getEntriesByType('navigation').forEach(entry => {
console.log('Navigation timing:', entry);
});
performance.getEntriesByType('resource').forEach(entry => {
if (entry.nextHopProtocol === 'h2') {
console.log(`${entry.name} loaded via HTTP/2`);
// Pushed resources have zero or near-zero request time
if (entry.requestStart === entry.fetchStart) {
console.log(' -> Likely server pushed');
}
}
});
Use server push sparingly. It works best for critical, cacheable resources on the initial page load. For subsequent navigations, proper cache headers are more efficient.
Header Compression with HPACK
HTTP headers are verbose and repetitive. Every request to the same server sends similar headers: cookies, authorization tokens, user agents, and accept headers. On HTTP/1.1, a typical request might include 800-1200 bytes of headers.
HPACK, HTTP/2’s header compression algorithm, addresses this through two mechanisms:
- Static table: Common headers (like
:method: GET) are predefined with indices - Dynamic table: Previously sent headers are indexed and referenced by number
- Huffman encoding: Header values are compressed using Huffman coding
Here’s a demonstration of the savings:
const http = require('http');
const http2 = require('http2');
const fs = require('fs');
// HTTP/1.1 header logging
const http1Server = http.createServer((req, res) => {
const headerSize = JSON.stringify(req.headers).length;
console.log(`HTTP/1.1 header size: ${headerSize} bytes`);
res.end('OK');
}).listen(8080);
// HTTP/2 header logging
const http2Server = http2.createSecureServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt')
});
http2Server.on('stream', (stream, headers) => {
// Headers are already decompressed here, but we can track frame sizes
const headerSize = JSON.stringify(headers).length;
console.log(`HTTP/2 header size (decompressed): ${headerSize} bytes`);
stream.respond({ ':status': 200 });
stream.end('OK');
});
http2Server.listen(8443);
In practice, HPACK reduces header overhead by 30-80%. For an API receiving thousands of requests with large authorization headers, this translates to significant bandwidth savings.
Implementing HTTP/2 in Node.js
Here’s a production-ready HTTP/2 server incorporating all three features:
const http2 = require('http2');
const fs = require('fs');
const path = require('path');
const server = http2.createSecureServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt'),
allowHTTP1: true // Fallback for older clients
});
const staticFiles = {
'/styles.css': { content: fs.readFileSync('./public/styles.css'), type: 'text/css' },
'/app.js': { content: fs.readFileSync('./public/app.js'), type: 'application/javascript' }
};
server.on('stream', (stream, headers) => {
const reqPath = headers[':path'];
// Handle root with server push
if (reqPath === '/') {
// Push critical resources
Object.keys(staticFiles).forEach(filePath => {
stream.pushStream({ ':path': filePath }, (err, pushStream) => {
if (err) {
console.error('Push error:', err);
return;
}
pushStream.respond({
'content-type': staticFiles[filePath].type,
':status': 200,
'cache-control': 'public, max-age=3600'
});
pushStream.end(staticFiles[filePath].content);
});
});
stream.respond({
'content-type': 'text/html',
':status': 200
});
stream.end(fs.readFileSync('./public/index.html'));
return;
}
// Serve static files
if (staticFiles[reqPath]) {
stream.respond({
'content-type': staticFiles[reqPath].type,
':status': 200,
'cache-control': 'public, max-age=3600'
});
stream.end(staticFiles[reqPath].content);
return;
}
// 404
stream.respond({ ':status': 404 });
stream.end('Not found');
});
server.on('error', (err) => console.error('Server error:', err));
server.listen(8443, () => {
console.log('HTTP/2 server running on https://localhost:8443');
});
Remember: browsers require HTTPS for HTTP/2. For local development, generate self-signed certificates with OpenSSL.
Performance Testing and Migration Considerations
Benchmark HTTP/2 against HTTP/1.1 with realistic workloads:
const autocannon = require('autocannon');
async function benchmark() {
console.log('Testing HTTP/1.1...');
const http1Result = await autocannon({
url: 'http://localhost:8080',
connections: 10,
duration: 10
});
console.log('Testing HTTP/2...');
const http2Result = await autocannon({
url: 'https://localhost:8443',
connections: 10,
duration: 10
});
console.log('\nResults:');
console.log(`HTTP/1.1: ${http1Result.requests.average} req/s`);
console.log(`HTTP/2: ${http2Result.requests.average} req/s`);
}
benchmark();
HTTP/2 shines with multiple small resources. For single large resources or high-latency networks, the benefits are minimal. Don’t blindly migrate—measure your specific use case.
Best Practices and Common Pitfalls
Do:
- Use HTTP/2 for applications with many small resources
- Implement proper cache headers to complement server push
- Monitor pushed resources to avoid cache waste
- Keep connections alive with ping frames for long-lived connections
Don’t:
- Concatenate assets (defeats multiplexing benefits)
- Use domain sharding (counterproductive with single connection)
- Push everything (be selective about critical resources)
- Ignore HTTP/1.1 fallback for older clients
HTTP/3 is on the horizon, built on QUIC instead of TCP. It eliminates TCP-level head-of-line blocking and improves connection establishment. But HTTP/2 remains the practical choice for production applications today, offering substantial performance improvements with broad support and proven reliability.