This post will explain what HTTP/2 is, and how we can make use of its features in Node.js.

banner image

HTTP/2 is the next version of the Hyper Text Transport Protocol (HTTP), which adds many features and optimizations over the previous version.

In the next few sections, we will understand these features in detail, and also learn how to implement them as a client and server using the Node.js http2 standard library.

If you just want to see the example code, you can view the repo on Github

HTTP/2 Connections and Sessions

To understand how to implement HTTP/2, we should first understand how requests are made between the client and server.

In HTTP/2, multiple requests between the client and the server are sent over the same TCP connection. This is the main difference from the older HTTP implementation, where each request required a new TCP connection to be established.

HTTP2: Multiple requests multiplexed over a single TCP connection

The HTTP/2 protocol is able to achieve this by interleaving data from multiple requests over the same connection:

requests are broken into multiple frames that are interleaved and sent over the same connection

This makes it much more efficient in utilizing the same connection, since the client and server can now exchange any number of requests simultaneously, and is not limited by the total number of TCP connections.

We can implement this connection by creating an HTTP2Session in our code:

// file: ./index.js

const http2 = require('http2')

// The `http2.connect` method creates a new session with example.com
const session = http2.connect('https://example.com')

// If there is any error in connecting, log it to the console
session.on('error', (err) => console.error(err))

The session object here represents the persistent connection between the client and the server. We can use this object in the next section to create and send a request to the server.

Making Client Side Requests

In HTTP/2, all requests are sent over the same persistent connection.

When we make a new request, a new “stream” is created over the session. The request data, and corresponding response data is sent through this stream.

To initiate a request, we can use the session.request method. This sends the request and creates a new stream object, which we can use to send and receive data:

// file: ./index.js

// Here, req is a new request stream
const req = session.request({ ':path': '/' })
// since we don't have any more data to send as
// part of the request, we can end it
req.end()

// This callback is fired once we receive a response
// from the server
req.on('response', (headers) => {
  // we can log each response header here
  for (const name in headers) {
    console.log(`${name}: ${headers[name]}`)
  }
})

// To fetch the response body, we set the encoding
// we want and initialize an empty data string
req.setEncoding('utf8')
let data = ''

// append response data to the data string every time
// we receive new data chunks in the response
req.on('data', (chunk) => { data += chunk })

// Once the response is finished, log the entire data
// that we received
req.on('end', () => {
  console.log(`\n${data}`)
  // In this case, we don't want to make any more
  // requests, so we can close the session
  session.close()
})

If we run this code, it will print the HTML response headers and data from example.com:

:status: 200
age: 182287
cache-control: max-age=604800
content-type: text/html; charset=UTF-8
date: Sun, 15 Aug 2021 11:10:22 GMT
etag: "3147526947+ident"
expires: Sun, 22 Aug 2021 11:10:22 GMT
last-modified: Thu, 17 Oct 2019 07:18:26 GMT
server: ECS (oxr/830D)
vary: Accept-Encoding
x-cache: HIT
content-length: 1256

<!doctype html>
<html>
<head>
    <title>Example Domain</title>

    <meta charset="utf-8" />
    <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style type="text/css">
    body {
...
...
...

Some of the request and response headers are prefixed by a semi-colon (like :path, and :status) - these are called pseudo-headers, and are special header fields that form part of the request and response:

  • :method specifies the HTTP method for the request (GET, POST, PUT, etc)
  • :path is the portion after the domain name that request goes to. So for sending a request to example.com/somepath, we connect to example.com, and specify /somepath in the :path header
  • :scheme and :authority are the remaining two request headers, which is out of scope for this post, but you can read more about them in the official RFC specification
  • :status is the only response pseudo-header, which tells us the HTTP status response code

Sending Request Body Data

We can send HTTP/2 requests containing a body payload using the request stream.

We can use the req objects write method to do this:

// file: ./index.js

const req = session.request({
    ':path': '/send-data',
    ':method': 'POST'
})
// consider an object with some sample data
const sampleData = { somekey: "somevalue" }
// we can convert this into a string and call
// the req.write method
// the second argument specifies the encoding, which is utf8 for now
req.write(JSON.stringify(sampleData), 'utf8')
req.end()

We can call res.write multiple times, and finally call res.end once we’re done.

HTTP/2 request and response sessions extend the stream.Duplex class, so you can treat them like regular stream objects to send and receive data

Implementing an HTTP/2 Server

Now that we’re able to send and receive data from our client, let’s create an HTTP/2 server that accept and respond to these requests.

// file: ./server.js

const http2 = require('http2')

// create a new server instance
const server = http2.createServer()

// log any error that occurs when running the server
server.on('error', (err) => console.error(err))

// the 'stream' callback is called when a new
// stream is created. Or in other words, every time a
// new request is received
server.on('stream', (stream, headers) => {
  // we can use the `respond` method to send
  // any headers. Here, we send the status pseudo header
  stream.respond({
    ':status': 200
  })

  // response streams are also stream objects, so we can
  // use `write` to send data, and `end` once we're done
  stream.write('Hello World!')
  stream.end()
})

// start the server on port 8000
server.listen(8000)

This server will respond to all requests with “Hello World!” as a text response.

We can now run the server, and change our client so that it connects with this server instead of example.com:

// file: ./index.js

const client = http2.connect('http://localhost:8000');

If we execute our client in another terminal, we can see the output from our own server:

:status: 200
date: Wed, 18 Aug 2021 10:43:37 GMT

Hello World!

Adding Routes to Our Server

In the above implementation of our server, it will respond to all paths and methods with the same “Hello World!” response.

We can implement a router as a new module, that will handle requests differently based on the path and method.

// file: ./router.js

// this is our original handler, extracted as a function
const helloWorldHandler = (stream, headers) => {
    console.log({ headers })
    stream.respond({
        ':status': 200
    })
    stream.end('Hello World')
}

// the pingHandler returns "pong" to let us know that
// the server is up and running
const pingHandler = (stream, headers) => {
    console.log({ headers })
    stream.respond({
        ':status': 200
    })
    stream.end('pong')
}

// in case a route doesn't exist, we want to return a
// 404 status and a message that the path is not found
const notFoundHandler = (stream, headers) => {
    stream.respond({
        'content-type': 'text/plain; charset=utf-8',
        ':status': 200
    })
    stream.end('path not found')
}

// the router is itself a special type of handler
// where we send the original request to another
// handler based on the pseudo headers
const router = (stream, headers) => {
    // first, extract the path and method pseudo headers
    const path = headers[':path']
    const method = headers[':method']

    // we can use a simple if-else ladder to determine the
    // final destination for the request stream and assign
    // it to the `handler` variable
    let handler
    if (path === "/hello-world" && method === 'GET') {
        handler = helloWorldHandler
    }
    else if (path === "/ping" && method == 'GET') {
        handler = pingHandler
    }
    else {
        handler = notFoundHandler
    }

    // finally, apply the chosen handler to the request
    handler(stream, headers)
}

module.exports = router

We can then import the router in our server file and replace the original handler with the router module:

// file: ./server.js

const router = require('./router')

//...
//...

server.on('stream', router)

Now every request will be sent to the main router handler, and passed off to one of the stream handlers depending on the path and method:

router architecture to route requests

Setting Request Timeouts

Timeouts are important if you want to prevent a slow client or server causing other requests to fail.

We can define a timeout as the maximum amount of time that the client is willing to wait before considering the server unresponsive, and exiting early.

As an example, let’s see how we can set a 100ms timeout on our client request for calling the ping handler:

// file: ./index.js

//...

const req = client.request({ ':path': '/ping' })

// we can use the setTimeout method to specify when
// to call the callback
req.setTimeout(100, () => {
  // within the callback, we log a message for our information
  // and close the request
  console.log('request timed out')
  req.close()
})

//...

This will lead to the client closing the request if 100 milliseconds have passed without the server sending its response.

We can make changes on the pingHandler as well, since we don’t want to spend resources and process a request if it’s already closed:

// file: ./router.js

const pingHandler = (stream, headers) => {
    console.log({ headers })

    // we don't want to respond to a stream if
    // it has been closed, so we check that here
    if (stream.closed) {
        return
    }
    stream.respond({
        ':status': 200
    })
    stream.end('pong')
}

Now, both the client and server will be spared from spending unnecessary resources.

We can test this by simulating a slow ping handler:

// file: ./router.js

const pingHandler = (stream, headers) => {
  // add a fake timeout of 750ms
    setTimeout(() => {
        if (stream.closed) {
            return
        }
        console.log({ headers })
        stream.respond({
            ':status': 200
        })
        stream.end('pong')
    }, 750)
}

If you execute the client now, you will get this output:

request timed out

Adding SSL/TLS Certificates (HTTPS)

This section will describe how to enable the HTTPS protocol on our HTTP/2 server.

Until now, our server was using the plaintext HTTP protocol. In modern web applications, we need to add secure encryption in the form of SSL/TLS certificates, to use the HTTPS protocol.

To do this, we need to generate a certificate and a private key. We can use the OpenSSL command line tool for this:

openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -nodes -days 3650

Running this command will generate a cert.pem file (the certificate), and a key.pem file (the private key)

Note: The certificate created here is a self-signed certificate which is fine for this example, but would be considered insecure by browsers and public clients. For production usage, consider using a signing authority like LetsEncrypt for creating your certificates.

We can now create a secure server with these files:

// file: ./server.js

const http2 = require('http2')
const fs = require('fs')

const server = http2.createSecureServer({
  // we can read the certificate and private key from
  // our project directory
  key: fs.readFileSync('key.pem'),
  cert: fs.readFileSync('cert.pem')
})

//...

// listen to 8443 as a convention for HTTPS
server.listen(8443)

We also have to change the client code to be able to trust our self-signed certificate:

// file: ./index.js

const http2 = require('http2')
const fs = require('fs')

const client = http2.connect('https://localhost:8443', {
	// we don't have to do this if our certificate is signed by
	// a recognized certificate authority, like LetsEncrypt
  ca: fs.readFileSync('cert.pem')
})

We can now run the client and the server, and they will work as usual. However, under the hood they will be using HTTPS, instead of HTTP.

That concludes the basics of HTTP/2 in Node.js! For reference, you can find the complete example code on Github

If you’re creating an application for the modern web, it’s highly recommended to use HTTP/2 for its many advantages over HTTP1.

Have you worked with HTTP/2 so far? What is your favorite new feature? Let me know in the comments!