HTTP/2 multiplexing vs HTTP/1.1 head-of-line blocking, Node.js http2 module, Fastify HTTP/2 setup, server push for resource preloading, gRPC over HTTP/2 with Protocol Buffers, h2c (cleartext) vs h2 (TLS), and the protocol selection decision matrix: REST/HTTP1.1 vs REST/HTTP2 vs gRPC vs WebSocket vs SSE by latency, payload size, and client type.
Module A-21 — HTTP/2, gRPC Transport, and Protocol Selection
What this module covers: In 2015, HTTP/2 became a standard. In 2025, most Node.js internal APIs still use HTTP/1.1. Not because HTTP/2 is hard — Node.js has native HTTP/2 support. Because nobody told the engineers it was an option, or why it matters. This module gives you the mental model to make the right protocol decision for every service boundary.
What HTTP/1.1 Gets Wrong
HTTP/1.1 has head-of-line blocking at the TCP level. To send request B, you must wait for response A to complete (or open a new connection). Browsers work around this by opening 6 connections per origin. Server-to-server communication typically uses 1 connection, or a small pool.
For an API that returns 10 resources from 10 endpoints, HTTP/1.1 requires 10 sequential round trips (or 10 parallel connections). At 20ms per round trip, that's 200ms minimum.
HTTP/2 multiplexes multiple requests over a single TCP connection. All 10 requests go out in the same TCP segment. All 10 responses come back as soon as the data is ready. Total time: 20ms + max(data fetch times).
HTTP/2 in Node.js
Native HTTP/2 module — no dependencies:
javascriptimport http2 from 'node:http2' import fs from 'node:fs' // HTTPS (required for h2 in browsers — cleartext h2c only for server-to-server) const server = http2.createSecureServer({ key: fs.readFileSync('./server.key'), cert: fs.readFileSync('./server.crt'), }) server.on('stream', (stream, headers) => { const path = headers[':path'] const method = headers[':method'] if (path === '/api/products' && method === 'GET') { stream.respond({ ':status': 200, 'content-type': 'application/json', }) stream.end(JSON.stringify({ products: [] })) } }) server.listen(443)
Fastify with HTTP/2 (recommended — production-ready):
javascriptimport Fastify from 'fastify' const fastify = Fastify({ http2: true, https: { key: readFileSync('./server.key'), cert: readFileSync('./server.crt'), } }) fastify.get('/api/products', async (request, reply) => { return { products: await getProducts() } }) await fastify.listen({ port: 443 })
No application-level code changes needed. Fastify handles the HTTP/2 protocol transparently.
Server Push — Preloading Resources
HTTP/2 allows the server to push resources the client will need before the client asks for them. A request for /dashboard can push /api/user, /api/notifications, and /api/alerts simultaneously:
javascriptserver.on('stream', (stream, headers) => { if (headers[':path'] === '/dashboard') { // Push user data before client requests it stream.pushStream({ ':path': '/api/user' }, (err, pushStream) => { if (!err) { pushStream.respond({ ':status': 200, 'content-type': 'application/json' }) pushStream.end(JSON.stringify(currentUser)) } }) // Respond to the original request stream.respond({ ':status': 200, 'content-type': 'text/html' }) stream.end(renderDashboardHTML()) } })
Server push is controversial in browser contexts (browsers have gotten good at avoiding redundant pushes). For server-to-server, it's a clean way to batch multiple resources into one round trip.
gRPC — When Protocol Buffers Beat JSON
gRPC is an RPC framework that uses Protocol Buffers (protobuf) for serialization and HTTP/2 for transport. It's the right choice when:
- You control both client and server
- Type safety across service boundaries matters
- Payload size at high throughput matters (protobuf is 3-5x smaller than equivalent JSON)
- Bidirectional streaming between services is needed
Define the service contract:
protobuf// orders.proto syntax = "proto3"; service OrderService { rpc GetOrder (GetOrderRequest) returns (Order); rpc CreateOrder (CreateOrderRequest) returns (Order); rpc StreamOrderUpdates (OrderFilter) returns (stream OrderUpdate); } message GetOrderRequest { string id = 1; } message Order { string id = 1; string user_id = 2; int64 amount_cents = 3; string status = 4; int64 created_at = 5; } message OrderUpdate { string order_id = 1; string new_status = 2; int64 updated_at = 3; }
Server implementation:
javascriptimport grpc from '@grpc/grpc-js' import protoLoader from '@grpc/proto-loader' const packageDef = protoLoader.loadSync('./orders.proto') const proto = grpc.loadPackageDefinition(packageDef) const server = new grpc.Server() server.addService(proto.OrderService.service, { getOrder: async (call, callback) => { const order = await db.orders.findUnique({ where: { id: call.request.id } }) if (!order) return callback({ code: grpc.status.NOT_FOUND }) callback(null, order) }, streamOrderUpdates: (call) => { const filter = call.request const unsub = orderEvents.on('update', (update) => { if (matchesFilter(update, filter)) { call.write(update) } }) call.on('cancelled', () => unsub()) } }) server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => { server.start() })
The N+1 problem in gRPC: gRPC calls are still point-to-point. If your order service calls the user service for each order, you have an N+1 problem over gRPC. Use bidirectional streaming or batch RPCs (BatchGetUsers) to solve this.
Protocol Selection Decision Matrix
| Scenario | Protocol | Reason |
|---|---|---|
| Browser API (public) | REST/HTTP1.1 | Maximum compatibility |
| Browser API (internal, controlled) | REST/HTTP2 | Multiplexing, connection efficiency |
| Service-to-service (same org) | gRPC/HTTP2 | Type safety, smaller payload, streaming |
| Service-to-service (third party) | REST/HTTP1.1 | Universal compatibility |
| Live data feed (client reads) | SSE/HTTP2 | Simple, browser-native, reconnects |
| Bidirectional real-time | WebSocket | Only protocol that is truly bidirectional |
| High-frequency small messages | gRPC streaming | Lower per-message overhead than WebSocket |
| Mobile clients, poor networks | REST/HTTP2 | Header compression, multiplexing |
| File streaming (large) | REST/HTTP1.1 chunked | Simplest, widest support |
HTTP/2 vs WebSocket for Real-Time
A common question: why use WebSocket at all if HTTP/2 has multiplexing?
HTTP/2 is still fundamentally request-response. Each stream starts with a request and ends with a response. The server can push streams, but the client cannot initiate bidirectional data flow without a new request.
WebSocket is a full-duplex pipe. Both sides can send data at any time without a pending request. For collaborative features (shared cursors, live document editing, multiplayer game state), WebSocket is the right choice. For data the server pushes to subscribing clients (notifications, price updates, live scores), SSE over HTTP/2 is simpler and sufficient.
The rule: if the client also needs to send unsolicited messages to the server, WebSocket. If only the server sends to clients, SSE.