Set up a Node.js HTTP/2 server with HTTP/1.1 fallback for static files
Learn how to create a secure HTTP/2 server that serves static assets and automatically falls back to HTTP/1.1 for unsupported clients using Node.js built‑in modules.
06 Jul 2025, 21:33 UTC

Desired outcome
Run a Node.js process that listens on two ports: an HTTPS port that speaks HTTP/2 to capable clients and an HTTP port that serves the same static content over HTTP/1.1 when the client cannot negotiate HTTP/2. The server should respond with the correct status code and content‑type for existing files and return 404 for missing resources.
Prerequisites
- Node.js version 18.0 or newer (the
http2module is stable from this release). - A TLS certificate and private key. For testing you can generate a self‑signed pair with OpenSSL; browsers will warn unless you add the certificate to the trust store.
- A directory containing the static files you wish to serve (e.g.,
./public). - Basic familiarity with the command line and file system permissions.
Procedure
- Create the project folder and install nothing – the built‑in modules are sufficient.
mkdir http2-static && cd http2-static mkdir public # copy your assets into ./public or create a test file: echo '<h1>Hello HTTP/2</h1>' > public/index.html - Prepare TLS credentials. Replace the placeholders with your actual file paths.
# Example: generate a self‑signed cert (run once) openssl req -newkey rsa:2048 -nodes -keyout key.pem -x509 -days 365 -out cert.pem -subj "/C=US/ST=Test/L=Test/O=Example/CN=localhost"Keep
key.pemsecure; never commit it to a public repository. - Write the server script (
server.js).const http2 = require('http2'); const http = require('http'); const fs = require('fs').promises; const path = require('path'); const OPTIONS = { key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem') }; const PORT_H2 = 8443; // HTTPS / HTTP/2 const PORT_HTTP = 8080; // HTTP/1.1 fallback const PUBLIC_DIR = path.resolve(__dirname, 'public'); function respondWithFile(stream, filePath, status = 200) { fs.access(filePath, fs.constants.R_OK) .then(() => fs.readFile(filePath)) .then(content => { const ext = path.extname(filePath).toLowerCase(); let mime = 'application/octet-stream'; if (ext === '.html') mime = 'text/html'; else if (ext === '.css') mime = 'text/css'; else if (ext === '.js') mime = 'application/javascript'; else if (ext === '.json') mime = 'application/json'; else if (ext === '.png') mime = 'image/png'; else if (ext === '.jpg' || ext === '.jpeg') mime = 'image/jpeg'; stream.respond({ 'content-type': mime, ':status': status }); stream.end(content); }) .catch(() => { stream.respond({ ':status': 404 }); stream.end('Not Found'); }); } // ----- HTTP/2 server (HTTPS) ----- const h2Server = http2.createSecureServer(OPTIONS); h2Server.on('stream', (stream, headers) => { const method = headers[':method']; const rawPath = headers[':path'] || '/' // Normalize path, prevent directory traversal let safePath = decodeURIComponent(rawPath).replace(/\/\.\./g, ''); if (safePath.endsWith('/')) safePath += 'index.html'; const filePath = path.join(PUBLIC_DIR, safePath); if (method === 'GET') { respondWithFile(stream, filePath); } else { stream.respond({ ':status': 405 }); // Method Not Allowed stream.end(); } }); h2Server.listen(PORT_H2, () => { console.log(`HTTP/2 server listening on https://localhost:${PORT_H2}`); }); // ----- HTTP/1.1 fallback server ----- const httpServer = http.createServer((req, res) => { if (req.method !== 'GET') { res.statusCode = 405; res.end('Method Not Allowed'); return; } let safePath = decodeURIComponent(req.url).replace(/\/\.\./g, ''); if (safePath.endsWith('/')) safePath += 'index.html'; const filePath = path.join(PUBLIC_DIR, safePath); fs.access(filePath, fs.constants.R_OK) .then(() => fs.readFile(filePath)) .then(content => { const ext = path.extname(filePath).toLowerCase(); let mime = 'application/octet-stream'; if (ext === '.html') mime = 'text/html'; else if (ext === '.css') mime = 'text/css'; else if (ext === '.js') mime = 'application/javascript'; else if (ext === '.json') mime = 'application/json'; else if (ext === '.png') mime = 'image/png'; else if (ext === '.jpg' || ext === '.jpeg') mime = 'image/jpeg'; res.setHeader('Content-Type', mime); res.statusCode = 200; res.end(content); }) .catch(() => { res.statusCode = 404; res.end('Not Found'); }); }); httpServer.listen(PORT_HTTP, () => { console.log(`HTTP/1.1 fallback listening on http://localhost:${PORT_HTTP}`); }); // Graceful shutdown (optional) process.on('SIGINT', () => { h2Server.close(); httpServer.close(); console.log('\nServers stopped'); process.exit(0); }); - Start the server.
node server.jsYou should see two log lines indicating the HTTPS and HTTP listeners are active.
Expected checks
- From a terminal, verify HTTP/2 negotiation:
curl --http2 -I https://localhost:8443/Look for a response header similar to
:status: 200and acontent-typeheader matching the requested file. - Verify the fallback works with a plain HTTP request:
curl -I http://localhost:8080/You should see
HTTP/1.1 200 OK(or 404 if the file does not exist) and the samecontent-typeas the HTTPS response. - Open
https://localhost:8443/in a browser. If you used a self‑signed certificate, you will need to proceed past the warning; the page should load and the browser’s developer tools (Network tab) will show the protocol ash2.
Limitations and practical verification
- The fallback server does not upgrade an HTTP/1.1 connection to HTTP/2; it simply serves the same content over a separate port. Clients that support HTTP/2 will still connect to the HTTPS port.
- Self‑signed certificates trigger browser warnings; for production use a certificate from a trusted CA or use tools like
mkcertto create locally trusted certs. - File‑system access is performed with
fs.promises; ensure the process has read permission on thepublicdirectory and all assets. - To confirm that the server is actually using HTTP/2, inspect the
:protocolpseudo‑header in the response (visible in curl verbose output-vor browser devtools).
Recovery options
If you need to stop the servers, press Ctrl+C in the terminal where node server.js is running. The SIGINT handler closes both listeners and exits the process. No persistent state is modified, so a rollback is not required beyond terminating the process.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.