HTTP/2 in Node Behind ALB: Multiplexing Pitfalls That Reset Streams

HTTP/2 in Node Behind ALB: Multiplexing Pitfalls That Reset Streams

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; log stream.id + rstCode on every reset; never share one long-lived http2 session 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

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

1 Comment

Leave a Reply