HTTP/2, gRPC Transport, and Protocol Selection16 min read
Module A-22·16 min read
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.
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.
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).
import 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'), })
The examples above all run HTTP/2 over TLS on the same port (443) that HTTP/1.1 traditionally uses. Client and server agree on which protocol to actually speak using ALPN (Application-Layer Protocol Negotiation), a TLS extension: during the TLS handshake, the client sends a list of protocols it supports (typically ["h2", "http/1.1"]), and the server picks one from that list and confirms it before the handshake completes. Only after ALPN has settled on h2 does either side start speaking the HTTP/2 binary framing layer — this is why createSecureServer needs no special "port routing" logic to support both protocols simultaneously; it's negotiated once, up front, inside the TLS handshake itself, not sniffed from the first bytes of application data.
// Node's http2 module handles ALPN negotiation internally — allowHTTP1 lets // the same server accept clients that didn't negotiate h2 (older clients, // health-check tooling, curl without --http2) and falls back to HTTP/1.1 // for them on the same port, same certificate. const server = http2.createSecureServer({ key: fs.readFileSync('./server.key'), cert: fs.readFileSync('./server.crt'), allowHTTP1: true, // ALPN falls back to 'http/1.1' for clients that don't offer 'h2' })
server.on('stream', (stream, headers) => { /* h2 clients land here */ }) server.on('request', (req, res) => { /* http/1.1 fallback clients land here */ })
Cleartext (non-TLS) connections have no TLS handshake at all, so there's no ALPN to negotiate — which is exactly the gap h2c (below) has to solve differently.
Everything shown so far is h2 — HTTP/2 over TLS, which is what browsers require. For trusted server-to-server traffic inside a private network (indexer-to-indexer, service-to-service behind a service mesh that already handles encryption at another layer), h2c runs the same HTTP/2 framing over a plain, unencrypted TCP connection — no certificates, no ALPN:
import http2 from 'node:http2'
// h2c: plain HTTP/2, no TLS. createServer() (not createSecureServer()) is the // cleartext entry point — appropriate for internal indexer-to-indexer calls // inside a trusted network boundary, never for anything crossing the public internet. const server = http2.createServer()
// h2c clients must know in advance the server speaks HTTP/2 — there's no TLS // ALPN handshake to discover it, so this is a prior-knowledge agreement // between the two services, not something negotiated per-connection. import http2 from 'node:http2'
Because there's no ALPN to fall back on, h2c requires both ends to have prior, out-of-band knowledge that the peer speaks HTTP/2 — there is no graceful negotiation or fallback to HTTP/1.1 mid-connection. This is fine for two services you deploy together, and inappropriate for anything facing an unknown or public client.
Every example so far has been server-side. On the client, http2.connect() opens a single persistent session to a host — analogous to Undici's Client from Module 19 — over which many concurrent streams multiplex:
// A session-level error means the whole connection is unusable — // e.g. TLS failure, DNS failure, connection refused session.on('error', (err) => { logger.error({ err }, 'HTTP/2 session error') })
// GOAWAY: the server is telling this session to stop opening new streams, // typically because the server is shutting down or load-shedding. Existing // streams already in flight are allowed to finish; new requests need a new // session (or should fail over to another replica). session.on('goaway', (errorCode, lastStreamID) => { logger.warn({ errorCode, lastStreamID }, 'server sent GOAWAY — no new streams on this session') session.close() // finish in-flight streams, then tear down cleanly })
function getRpcBlock(height) { return new Promise((resolve, reject) => { const req = session.request({ ':path': `/api/v1/blocks/${height}`, ':method': 'GET', })
let data = '' req.on('data', (chunk) => { data += chunk }) req.on('end', () => resolve(JSON.parse(data))) req.on('error', reject) req.end() }) }
// Multiple concurrent calls reuse the SAME session — this is the entire // point of HTTP/2 multiplexing over a single TCP connection const [block1, block2, block3] = await Promise.all([ getRpcBlock(100), getRpcBlock(101), getRpcBlock(102), ])
A session is not automatically reconnected after GOAWAY or a session-level error — the caller is responsible for opening a fresh http2.connect() (typically to another replica, if you're behind a load balancer that supports it) rather than assuming the existing session will heal itself.
A note on load balancers: many load balancers and ingress controllers terminate HTTP/2 from the client but speak HTTP/1.1 to the backend — a silent downgrade that happens entirely at the infrastructure layer, invisible to application code on either side. Before relying on end-to-end HTTP/2 semantics (multiplexing, server push, true binary framing) in production, confirm your specific load balancer/ingress actually passes HTTP/2 through to the backend rather than downgrading it; otherwise you're only getting HTTP/2 between the client and the edge, with plain HTTP/1.1 for the hop that likely has the most concurrent connections.
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:
server.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()) } })
Mainstream browser support for server push has been removed, not merely discouraged — Chrome dropped it entirely in Chrome 106 (2022), and Firefox removed it in version 132 (2024), both citing low real-world adoption and the difficulty of avoiding pushing resources the client already had cached. Don't design a browser-facing feature around pushStream; treat it as effectively unavailable for browser clients. For server-to-server communication — where you control both ends and there's no browser cache to reason about — pushStream remains a valid, working way to batch multiple resources into one round trip, but it's a shrinking niche rather than a growing one.
Choosing gRPC vs REST is like choosing a dedicated settlement rail over the public highway: gRPC is UPI's purpose-built instant-payment rail — narrow, fast, contract-bound — while REST/JSON is the highway any vehicle can drive on, slower per trip but universally accessible.
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
Client implementation, with deadlines and retries:
import grpc from '@grpc/grpc-js' import protoLoader from '@grpc/proto-loader'
const packageDef = protoLoader.loadSync('./orders.proto') const proto = grpc.loadPackageDefinition(packageDef)
const client = new proto.OrderService( 'orders-service.internal:50051', grpc.credentials.createInsecure(), )
function getOrderWithDeadline(orderId) { return new Promise((resolve, reject) => { // Deadlines are absolute wall-clock time, not a duration — this is a // common gRPC gotcha coming from HTTP timeout APIs that take milliseconds. const deadline = new Date(Date.now() + 2_000) // 2s from now
// Retries: @grpc/grpc-js supports a service-config retry policy so callers // don't have to hand-roll retry loops around every RPC. const client = new proto.OrderService( 'orders-service.internal:50051', grpc.credentials.createInsecure(), { 'grpc.service_config': JSON.stringify({ methodConfig: [{ name: [{ service: 'OrderService' }], retryPolicy: { maxAttempts: 3, initialBackoff: '0.1s', maxBackoff: '1s', backoffMultiplier: 2, // Only retry codes that are safe to retry — never retry a call that // may have already mutated state and only failed to return a response. retryableStatusCodes: ['UNAVAILABLE', 'DEADLINE_EXCEEDED'], }, }], }), }, )
Interceptors — auth and logging middleware for gRPC: gRPC interceptors are the RPC equivalent of Express/Fastify middleware — they wrap every call with cross-cutting concerns instead of repeating them in every handler.
// Server-side interceptor: verify a bearer token on every incoming call function authInterceptor(methodDescriptor, call) { return new grpc.ServerInterceptor(methodDescriptor, call, { start: (metadata, listener, next) => { const token = metadata.get('authorization')[0] if (!isValidToken(token)) { call.sendStatus({ code: grpc.status.UNAUTHENTICATED, details: 'invalid token' }) return } next(metadata, listener) }, }) }
// Client-side interceptor: attach the auth token and log every outbound call function loggingAuthInterceptor(options, nextCall) { return new grpc.InterceptingCall(nextCall(options), { start: (metadata, listener, next) => { metadata.set('authorization', `Bearer ${getServiceToken()}`) logger.info({ method: options.method_definition.path }, 'gRPC call starting') next(metadata, listener) }, }) }
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.
Production story: a team deployed a new gRPC service between the indexer and a settlement-status service, following the "same org → gRPC/HTTP2" row above. Every call failed immediately with an UNIMPLEMENTED status — the gRPC client's default first reaction is to assume a typo in the method name or a stale .proto file, and the team spent time re-checking the service contract and regenerating stubs. The actual cause was infrastructure: the load balancer sitting in front of the settlement-status service was several versions behind and did not support HTTP/2 passthrough — it silently downgraded every connection to HTTP/1.1 before forwarding it to the backend, and the gRPC server, receiving HTTP/1.1 framing it couldn't parse as gRPC, returned the generic UNIMPLEMENTED code. The cryptic error had nothing to do with the application code on either side; it traced back entirely to the load balancer's HTTP/2 support, confirmed only after packet-capturing the hop between the load balancer and the backend and seeing HTTP/1.1 framing where HTTP/2 was expected.
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.
This is the final module of Node.js In-Depth. The full arc, from the event loop's non-blocking model through V8 internals, kernel I/O, clustering, microservices, observability, security, and distribution, to this module's protocol-selection framework: you now have the mental models to reason about a Node.js system's behavior at every layer, from a single await down to which transport protocol a service-to-service call should use and why.
The system you can now build: a high-throughput Node.js service that uses kernel-level I/O efficiently, scales across all available CPU cores with minimal IPC overhead, maintains clean domain boundaries, defends against supply chain attacks and ReDoS, generates structured diagnostics at the exact moment of any failure, and chooses the right transport protocol for every connection it makes.
Knowledge Check
Why does upgrading the transport to HTTP/2 significantly reduce the latency for a client that makes many simultaneous API requests?
Why is gRPC often chosen over standard HTTP/1.1 REST for high-throughput, service-to-service communication?
Which protocol is the most efficient and simplest choice when a server continuously pushes live data updates to connected web clients, but the clients never send real-time data back?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.