Turning on HTTP/2 between ALB and Node looks free until you see NGHTTP2_REFUSED_STREAM, mysterious 502s under fan-out, and header frames that ALB accepts from clients but your Node http2 session rejects. Multiplexing amplifies every bad assumption about timeouts, header size, and concurrent streams. Senior teams treat HTTP/2 as a measured upgrade — not a checkbox — and keep HTTP/1.1 as the escape hatch.
⚡ TL;DR: Prefer ALB → target HTTP/1.1 unless you have measured head-of-line benefits and load-tested stream limits; if you enable HTTP/2 to Node, align
maxSessionMemory, header table sizes, and idle timeouts with ALB; logstream.id+rstCodeon every reset; never share one long-livedhttp2session across unrelated tenants without isolation. Pair with Node.js Event Loop Lag: Catch P99 Stalls when resets correlate with loop delay.
ALB termination vs end-to-end HTTP/2
ALB can speak HTTP/2 to clients while forwarding HTTP/1.1 to targets — that is usually what you want. End-to-end HTTP/2 only pays off when the target benefits from multiplexed upstream calls and you control both sides’ settings.
// src/server-h2.ts
import http2 from "node:http2";
import fs from "node:fs";
const server = http2.createSecureServer({
key: fs.readFileSync("./certs/key.pem"),
cert: fs.readFileSync("./certs/cert.pem"),
// ✅ Bound memory + streams — ALB will open many
maxSessionMemory: 32, // MB
peerMaxConcurrentStreams: 100,
settings: {
maxConcurrentStreams: 100,
maxHeaderListSize: 32 * 1024,
},
});
server.on("session", (session) => {
session.on("error", (err) => {
metrics.increment("h2.session_error", { code: (err as any).code });
});
});
server.on("stream", (stream, headers) => {
stream.on("error", (err) => {
metrics.increment("h2.stream_error", {
rst: String((err as any).code ?? "unknown"),
});
});
});
// ❌ Immortal session, unbounded streams, no error taxonomy
const global = http2.connect(ORIGIN);
export const get = (p: string) => global.request({ ":path": p });
Stream resets you will actually see
Resets are not always bugs — they are backpressure. The bug is when your client retries the whole fan-out on every REFUSED_STREAM and stampedes the ALB.
// src/h2-upstream.ts
import http2 from "node:http2";
export async function getJson(path: string): Promise<unknown> {
const client = http2.connect(process.env.UPSTREAM_H2_ORIGIN!);
try {
return await new Promise((resolve, reject) => {
const req = client.request({ ":method": "GET", ":path": path });
req.setEncoding("utf8");
let body = "";
req.on("data", (c) => (body += c));
req.on("error", (err: NodeJS.ErrnoException) => {
metrics.increment("h2.upstream_err", { code: err.code ?? "ERR" });
reject(err);
});
req.on("end", () => resolve(JSON.parse(body)));
req.end();
});
} finally {
client.close();
}
}
Header and cookie landmines
HTTP/2 forbids connection-specific headers (Connection, Transfer-Encoding, Upgrade). Middleware that injects Connection: keep-alive causes protocol errors. Underscores in header names and oversized cookie jars also trip ALB/Node mismatches.
const HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade"]);
export function sanitizeHeaders(h: Record<string, string | string[] | undefined>) {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(h)) {
if (v == null || HOP.has(k.toLowerCase())) continue;
if (k.includes("_")) continue;
out[k] = Array.isArray(v) ? v.join(",") : v;
}
return out;
}
When HTTP/1.1 is the correct production choice
- Target is Node with heavy per-request middleware and no upstream fan-out benefit.
- You rely on request-scoped connection state or buggy HTTP/2 client libraries.
- ALB idle timeout and Node session keepalive disagree (classic intermittent 502).
- You cannot get a clean load-test with resets under 0.01% at 2× peak RPS.
Stay on HTTP/1.1 to the target, keep H2 at the edge, and invest in connection pooling via undici instead — protocol choice should follow workload shape, same lesson as Lambda + Bedrock: Stream Tokens Without API Gateway Caps.
Closing checklist
✅ Dos
– ✅ Default ALB target protocol to HTTP/1.1 unless H2 is proven
– ✅ Cap maxConcurrentStreams and maxSessionMemory
– ✅ Metric rstCode / ERR_HTTP2_* with deploy version tags
– ✅ Sanitize hop-by-hop headers on any H2 hop
– ✅ Load-test multiplexed fan-out before enabling in prod
❌ Don’ts
– ❌ Don’t assume H2 to Node fixes HOL blocking from your own sync work
– ❌ Don’t share one client session across tenants without stream limits
– ❌ Don’t retry REFUSED_STREAM with full jitter storms
– ❌ Don’t ignore ALB 502s that cluster at idle timeout boundaries
– ❌ Don’t mix gRPC-h2c assumptions with ALB HTTPS targets casually
Related reading
- Node.js Event Loop Lag: Catch P99 Stalls
- Lambda + Bedrock: Stream Tokens Without API Gateway Caps
- OpenTelemetry for LLMs: Trace Prompt Latency Across Microservices
- Node SDK Generation: OpenAPI-Driven Clients LLMs Cannot Hallucinate
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.

Pingback: Consistent Hashing for WebSockets: Sticky Gateways During Scale Events - CheatCoders