HTTP/2, gRPC Transport, and Protocol Selection26 min read
Module A-22·26 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.
Module 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:
javascript
Fastify with HTTP/2 (recommended — production-ready):
javascript
No application-level code changes needed. Fastify handles the HTTP/2 protocol transparently.
ALPN: How Client and Server Actually Agree on h2 vs HTTP/1.1
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.
javascript
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.
h2c: Cleartext HTTP/2 (Server-to-Server)
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:
javascript
javascript
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.
Client-Side HTTP/2: http2.connect() and Session Lifecycle
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:
javascript
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.
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:
javascript
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.
gRPC — When Protocol Buffers Beat JSON
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
Define the service contract:
protobuf
Server implementation:
javascript
Client implementation, with deadlines and retries:
javascript
javascript
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.
javascript
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
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.
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.
Course Complete
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.
// 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 */})
importhttp2from'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()server.on('stream',(stream, headers)=>{const path = headers[':path']const method = headers[':method']if(path ==='/internal/verify-batch'&& method ==='POST'){let body =[] stream.on('data',(chunk)=> body.push(chunk)) stream.on('end',()=>{const transactions =JSON.parse(Buffer.concat(body))const results =verifyBatch(transactions)// e.g. the napi-rs addon from Module 18 stream.respond({':status':200,'content-type':'application/json'}) stream.end(JSON.stringify({ results }))})}})server.listen(8080)
// 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.importhttp2from'node:http2'const session = http2.connect('http://internal-indexer:8080')// note: http://, not https://const stream = session.request({':path':'/internal/verify-batch',':method':'POST'})stream.end(JSON.stringify(transactionBatch))
importhttp2from'node:http2'const session = http2.connect('https://blockchain-rpc.internal:443')// A session-level error means the whole connection is unusable —// e.g. TLS failure, DNS failure, connection refusedsession.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})functiongetRpcBlock(height){returnnewPromise((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 connectionconst[block1, block2, block3]=awaitPromise.all([getRpcBlock(100),getRpcBlock(101),getRpcBlock(102),])
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())}})
// orders.protosyntax="proto3";serviceOrderService{rpcGetOrder(GetOrderRequest)returns(Order);rpcCreateOrder(CreateOrderRequest)returns(Order);rpcStreamOrderUpdates(OrderFilter)returns(streamOrderUpdate);}messageGetOrderRequest{string id =1;}messageOrder{string id =1;string user_id =2;int64 amount_cents =3;string status =4;int64 created_at =5;}messageOrderUpdate{string order_id =1;string new_status =2;int64 updated_at =3;}
importgrpcfrom'@grpc/grpc-js'importprotoLoaderfrom'@grpc/proto-loader'const packageDef = protoLoader.loadSync('./orders.proto')const proto = grpc.loadPackageDefinition(packageDef)const server =newgrpc.Server()server.addService(proto.OrderService.service,{getOrder:async(call, callback)=>{const order =await db.orders.findUnique({where:{id: call.request.id}})if(!order)returncallback({code: grpc.status.NOT_FOUND})callback(null, order)},streamOrderUpdates:(call)=>{const filter = call.requestconst 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()})
importgrpcfrom'@grpc/grpc-js'importprotoLoaderfrom'@grpc/proto-loader'const packageDef = protoLoader.loadSync('./orders.proto')const proto = grpc.loadPackageDefinition(packageDef)const client =newproto.OrderService('orders-service.internal:50051', grpc.credentials.createInsecure(),)functiongetOrderWithDeadline(orderId){returnnewPromise((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 =newDate(Date.now()+2_000)// 2s from now client.getOrder({id: orderId },{ deadline },(err, order)=>{if(err){if(err.code=== grpc.status.DEADLINE_EXCEEDED){returnreject(newError(`order lookup for ${orderId} exceeded 2s deadline`))}returnreject(err)}resolve(order)})})}
// 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 =newproto.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'],},}],}),},)
// Server-side interceptor: verify a bearer token on every incoming callfunctionauthInterceptor(methodDescriptor, call){returnnewgrpc.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 callfunctionloggingAuthInterceptor(options, nextCall){returnnewgrpc.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)},})}const client =newproto.OrderService('orders-service.internal:50051', grpc.credentials.createInsecure(),{interceptors:[loggingAuthInterceptor]},)