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.

Written by Jatin Jain Saraf · Architect of SupraScan

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:

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'),
})
 
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):

import 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.

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.

// 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.

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:

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()
 
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.
import http2 from '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))

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

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.