Description
Is there an existing issue for this?
- I have searched the existing issues
Current behavior
HTTP/2 'HPE_PAUSED_H2_UPGRADE' Error with Node.js 22 LTS and NestJS (Fastify/Express)
Description
I'm encountering a persistent HTTP/2 'HPE_PAUSED_H2_UPGRADE' error
when trying to establish an HTTPS/HTTP/2 connection to a NestJS application (both with @nestjs/platform-express
and @nestjs/platform-fastify
adapters). The connection handshake appears to complete, but the server immediately returns an "Empty reply from server" (curl: 52) or a "PROTOCOL_ERROR (SETTINGS expected)" (nghttp).
This issue does not occur when running a simple, pure Node.js http2
server with the same Node.js version, certificates, and OS. This strongly suggests the problem lies within NestJS's handling or configuration of the underlying Node.js http2
module.
Steps to Reproduce
-
Environment:
- OS: Ubuntu 22.04 LTS
- Node.js: v22.17.9 LTS
curl
version: 7.81.0nghttp
version: 1.43.0- Certificates: Self-signed (
localhost
) generated via OpenSSL (method:openssl genrsa -out private-key.pem 2048
,openssl req -new -key private-key.pem -out public-cert.csr
,openssl x509 -req -in public-cert.csr -signkey private-key.pem -out public-cert.pem
).
-
NestJS
package.json
dependencies:{ "dependencies": { "@aws-sdk/client-s3": "^3.614.0", "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", "@nestjs/core": "^11.0.0", "@nestjs/mapped-types": "^2.0.5", "@nestjs/platform-express": "^11.0.0", "@nestjs/platform-fastify": "^11.0.0", "@nestjs/swagger": "^7.1.17", "@nestjs/typeorm": "^10.0.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "compression": "^1.7.4", "express": "^4.19.2", "mssql": "^11.0.1", "mysql": "^2.18.1", "reflect-metadata": "^0.2.2", "rimraf": "^5.0.7", "rxjs": "^7.8.1", "typeorm": "^0.3.20", "uuidv4": "^6.2.13", "fastify": "^4.28.0" }, "devDependencies": { "@nestjs/cli": "^10.3.2", "@nestjs/schematics": "^10.1.2", "@nestjs/testing": "^11.0.0", "@types/compression": "^1.7.5", "@types/express": "^4.17.21", "@types/jest": "^29.5.12", "@types/multer": "^1.4.11", "@types/node": "^20.14.10", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^7.15.0", "@typescript-eslint/parser": "^7.15.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.7.0", "prettier": "^3.3.2", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.0", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.5.3" } }
-
main.ts
configuration (using@nestjs/platform-express
for clientError debugging):// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; // Adjust path if necessary import { readFileSync } from 'fs'; import { INestApplication } from '@nestjs/common'; import { Server } from 'http'; // For Express, import Server from 'http' async function bootstrap() { const httpsOptions = { key: readFileSync(process.env.CERT_KEY_FILE as string), cert: readFileSync(process.env.CERT_PEM_FILE as string), ALPNProtocols: ['h2', 'http/1.1'], }; const app: INestApplication = await NestFactory.create(AppModule, { httpsOptions, logger: ['error', 'warn', 'debug'], }); // --- Client Error Debugging --- const httpServer: Server = app.getHttpServer(); httpServer.on('clientError', (err: any, socket: any) => { console.error('--- clientError caught on httpServer ---'); console.error('Error details:', err); console.error('Socket details:', socket.remoteAddress, socket.remotePort); if (socket.writable) { socket.destroy(); } }); httpServer.on('error', (err: any) => { console.error('--- General server error caught on httpServer ---'); console.error(err); }); // --- End Client Error Debugging --- // Ensure no other middlewares or global pipes are enabled for this test // e.g., app.use(compression()), app.enableCors(), app.useGlobalPipes(), etc. await app.listen(process.env.SERVER_PORT || 3000); console.log('Server running...'); } bootstrap();
(If you tried with Fastify, you can add a note here that it produced the same error).
-
Minimal
app.module.ts
andtest.controller.ts
(for a simple endpoint):// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TestController } from './test.controller'; // Create this simple controller @Module({ imports: [ConfigModule.forRoot()], controllers: [TestController], providers: [], }) export class AppModule {}
// src/test.controller.ts import { Controller, Get } from '@nestjs/common'; @Controller('test') export class TestController { @Get('hello') getHello(): string { return 'Hello from NestJS HTTP/2!'; } }
-
Start NestJS application:
npm run start:prod
(ornode dist/main.js
withprocess.env
variables set for cert paths and port) -
Run
curl
andnghttp
client tests:curl -vvv --http2 --insecure https://localhost:3000/test/hello nghttp -v https://localhost:3000/test/hello
Expected Behavior
The NestJS server should respond with the "Hello from NestJS HTTP/2!" message via HTTP/2.
Actual Behavior
Both curl
and nghttp
receive an "Empty reply from server" or a "PROTOCOL_ERROR", respectively. The NestJS server console logs the HPE_PAUSED_H2_UPGRADE
error.
curl
output:
* Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* TLSv1.0 (OUT), TLS header, Certificate Status (22):
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.2 (IN), TLS header, Certificate Status (22):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.2 (IN), TLS header, Finished (20):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.2 (OUT), TLS header, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
* ALPN, server accepted to use h2
* Server certificate:
* subject: CN=localhost
* start date: Jun 25 22:10:20 2025 GMT
* expire date: Jun 25 22:10:20 2026 GMT
* issuer: CN=localhost
* SSL certificate verify result: self-signed certificate (18), continuing anyway.
* Using HTTP2, server supports multiplexing
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* Using Stream ID: 1 (easy handle 0x6289ff4599f0)
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
> GET /test/hello HTTP/2
> Host: localhost:3000
> user-agent: curl/7.81.0
> accept: */*
>
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* old SSL session ID is stale, removing
* TLSv1.2 (IN), TLS header, Supplemental data (23):
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* Empty reply from server
* Closing connection 0
* TLSv1.2 (OUT), TLS header, Supplemental data (23):
* TLSv1.3 (OUT), TLS alert, close notify (256):
curl: (52) Empty reply from server
nghttp
output:
[ 0.003] Connected
[WARNING] Certificate verification failed: self-signed certificate
The negotiated protocol: h2
[ 0.010] send SETTINGS frame <length=12, flags=0x00, stream_id=0>
(niv=2)
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
[SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535]
[ 0.010] send PRIORITY frame <length=5, flags=0x00, stream_id=3>
(dep_stream_id=0, weight=201, exclusive=0)
[ 0.010] send PRIORITY frame <length=5, flags=0x00, stream_id=5>
(dep_stream_id=0, weight=101, exclusive=0)
[ 0.010] send PRIORITY frame <length=5, flags=0x00, stream_id=7>
(dep_stream_id=0, weight=1, exclusive=0)
[ 0.010] send PRIORITY frame <length=5, flags=0x00, stream_id=9>
(dep_stream_id=7, weight=1, exclusive=0)
[ 0.010] send PRIORITY frame <length=5, flags=0x00, stream_id=11>
(dep_stream_id=3, weight=1, exclusive=0)
[ 0.010] send HEADERS frame <length=47, flags=0x25, stream_id=13>
; END_STREAM | END_HEADERS | PRIORITY
(padlen=0, dep_stream_id=11, weight=16, exclusive=0)
; Open new stream
:method: GET
:path: /test/hello
:scheme: https
:authority: localhost:3000
accept: */*
accept-encoding: gzip, deflate
user-agent: nghttp2/1.43.0
[ 0.012] [ERROR] Remote peer returned unexpected data while we expected SETTINGS frame. Perhaps, peer does not support HTTP/2 properly.
[ 0.012] send GOAWAY frame <length=25, flags=0x00, stream_id=0>
(last_stream_id=0, error_code=PROTOCOL_ERROR(0x01), opaque_data(17)=[SETTINGS expected])
Some requests were not processed. total=1, processed=0
NestJS Server Console Output:
--- clientError caught on httpServer ---
Error details: [Error: Parse Error: Pause on PRI/Upgrade] {
bytesParsed: 24,
code: 'HPE_PAUSED_H2_UPGRADE',
reason: 'Pause on PRI/Upgrade',
rawPacket: <Buffer 50 52 49 20 2a 20 48 54 54 50 2f 32 2e 30 0d 0a 0d 0a 53 4d 0d 0a 0d 0a>
}
Socket details: undefined undefined
--- clientError caught on httpServer ---
Error details: [Error: Parse Error: Pause on PRI/Upgrade] {
bytesParsed: 24,
code: 'HPE_PAUSED_H2_UPGRADE',
reason: 'Pause on PRI/Upgrade',
rawPacket: <Buffer 00 00 12 04 00 00 00 00 00 00 03 00 00 00 64 00 04 02 00 00 00 00 02 00 00 00 00>
}
Socket details: undefined undefined
--- clientError caught on httpServer ---
Error details: [Error: Parse Error: Pause on PRI/Upgrade] {
bytesParsed: 24,
code: 'HPE_PAUSED_H2_UPGRADE',
reason: 'Pause on PRI/Upgrade',
rawPacket: <Buffer 00 00 04 08 00 00 00 00 00 01 ff 00 01>
}
Socket details: undefined undefined
--- clientError caught on httpServer ---
Error details: [Error: Parse Error: Pause on PRI/Upgrade] {
bytesParsed: 24,
code: 'HPE_PAUSED_H2_UPGRADE',
reason: 'Pause on PRI/Upgrade',
rawPacket: <Buffer 00 00 27 01 05 00 00 00 01 82 04 88 61 25 42 58 9c b4 50 7f 87 41 8a a0 e4 1d 13 9d 09 b8 c8 00 0f 7a 88 25 b6 50 c3 ab bc 15 c1 53 03 2a 2f 2a>
}
Socket details: undefined undefined
Minimum reproduction code
Provided in description.
Steps to reproduce
npm i
npm run start:prod
call curl/nghttp on https://localhost:3001/test/hello
Expected behavior
Receive content on HTTP2 instead of "Empty reply from server".
Package
- I don't know. Or some 3rd-party package
-
@nestjs/common
-
@nestjs/core
-
@nestjs/microservices
-
@nestjs/platform-express
-
@nestjs/platform-fastify
-
@nestjs/platform-socket.io
-
@nestjs/platform-ws
-
@nestjs/testing
-
@nestjs/websockets
- Other (see below)
Other package
No response
NestJS version
11.0.0
Packages versions
{
"name": "apptalk-backend",
"version": "0.0.1",
"description": "Lisa [AppTalk] API [backend] app",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.614.0",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/mapped-types": "^2.0.5",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/platform-fastify": "^11.0.0",
"@nestjs/swagger": "^7.1.17",
"@nestjs/typeorm": "^10.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"compression": "^1.7.4",
"express": "^4.19.2",
"mssql": "^11.0.1",
"mysql": "^2.18.1",
"reflect-metadata": "^0.2.2",
"rimraf": "^5.0.7",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"uuidv4": "^6.2.13",
"fastify": "^4.28.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.2",
"@nestjs/testing": "^11.0.0",
"@types/compression": "^1.7.5",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/multer": "^1.4.11",
"@types/node": "^20.14.10",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@typescript-eslint/parser": "^7.15.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
"prettier": "^3.3.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.0",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
Node.js version
22.17.0
In which operating systems have you tested?
- macOS
- Windows
- Linux
Other
When I run an clean Node example, it works:
const http2 = require('http2');
const fs = require('fs');
const KEY_PATH = './cert/private-key.pem';
const CERT_PATH = './cert/public-cert.pem';
const options = {
key: fs.readFileSync(KEY_PATH),
cert: fs.readFileSync(CERT_PATH),
allowHTTP1: true,
ALPNProtocols: ['h2', 'http/1.1']
};
const server = http2.createSecureServer(options);
server.on('error', (err) => console.error('Server error (pure http2):', err));
server.on('stream', (stream, headers) => {
console.log([${new Date().toISOString()}] Pure HTTP/2 Stream received. Path: ${headers[':path']}
);
const payload = JSON.stringify({ status: 'ok', protocol: 'HTTP/2 from pure Node.js' });
const headersToSend = {
'content-type': 'application/json',
':status': 200,
'content-length': Buffer.byteLength(payload)
};
try {
stream.respond(headersToSend);
stream.end(payload);
console.log([${new Date().toISOString()}] Pure HTTP/2 Response sent for ${headers[':path']}
);
} catch (e) {
console.error([${new Date().toISOString()}] ERROR sending pure HTTP/2 stream:
, e);
}
});
server.listen(3001, () => {
console.log('---');
console.log('Pure HTTP/2 server listening on https://localhost:3001');
console.log('---');
});