A QUIC Update for Node.js


Head of Research
Javascript, Node.js, Open Source | 12th February 2020

In March of last year, thanks to support from NearForm and Protocol Labs, I started the process of implementing Node.js support for the new QUIC protocol. This new UDP-based transport protocol is intended to ultimately replace the use of TCP for all HTTP traffic.

Anyone familiar with UDP should, at this point, rightfully question why it is being used here. UDP is notoriously unreliable, with packets frequently being lost, reordered, duplicated, and so forth. UDP does not include any of the reliability and sequencing guarantees of TCP that are strictly required for a higher-level protocol such as HTTP. That is where QUIC comes in.

The QUIC protocol defines a layer on top of UDP that introduces error handling, reliability, flow control, and built-in security (via TLS 1.3) to UDP. In effect, it reimplements the majority of TCP on top of UDP with one key difference: unlike TCP, it is still possible to transmit packets out of sequence. Understanding why this is important is critical to understanding why QUIC is superior to TCP.

QUIC Eliminates Head of Line Blocking

In HTTP/1, all messages exchanged between the client and server are in the form of contiguous, uninterrupted blocks of data. While it is possible to send multiple requests or responses over a single TCP connection, one complete message must be fully transmitted before beginning to send the next complete message. That means if you want to send one 10 megabyte file and then one 2 megabyte file, the former would have to be fully transmitted before the latter could even be started. This is known as Head of Line Blocking and the source of significant latency and poor use of network bandwidth.

HTTP/2 attempted resolving this issue by introducing multiplexing. Rather than transmitting requests and responses as contiguous streams, HTTP/2 divides those into discrete chunks called frames that can be interlaced with other frames. A single TCP connection can handle a theoretically unlimited number of concurrent request and response flows. This works OK in theory but the design of HTTP/2 failed to consider the possibility of Head of Line Blocking at the TCP layer.

TCP itself is a strictly sequenced protocol. Packets of data are serialized and sent over the network in a fixed order. If a packet fails to reach its destination, the entire stream of packets is held up until the lost packet can be re-transmitted. The sequence is effectively, Send Packet 1, Wait for Acknowledgement, Send Packet 2, Wait for Acknowledgement, Send Packet 3 … and so on. With HTTP/1, where only one HTTP message can be transmitted at any given time, if a single TCP packet gets lost, then only a single HTTP request/response flow at a time is impacted by the loss and re-transmission of that packet. However, with HTTP/2, it is possible to block the transmission of an unlimited number of concurrent HTTP request/response flows with loss of a single TCP packet. When transmitting HTTP/2 traffic over high latency, low-reliability networks, overall performance and network throughput drops dramatically in comparison to HTTP/1.

In HTTP/1 the line is blocked because only one complete message can be sent at a time.
In HTTP/2 the line is blocked when a single TCP packet is lost or corrupted.
In QUIC, packets are independent of one another and may be sent (or re-sent) in any order.

With QUIC, thankfully the situation is different. When a stream of data is packaged into discrete UDP packets for transmission to the remote peer, any single packet may be sent (or resent) in any order without impacting any other packet sent. In other words, the Head of Line Blocking issue should be largely resolved.

QUIC introduces Inherent Flexibility, Security & Reduced Latency

QUIC introduces a number of other important features:

  • A QUIC Connection operates independently of the network topology. Once a QUIC connection is established, both the source and destination IP addresses and ports may change without requiring re-establishment of the connection. Where this becomes particularly useful is on mobile devices that are switching from one type of network to another (e.g. LTE to WiFi).
  • QUIC connections are secured and encrypted by default. TLS 1.3 support is baked directly into the protocol and all QUIC traffic is encrypted.
  • QUIC adds critical flow control and error handling to UDP and includes important security mechanisms to prevent a range of Denial of Service attacks.
  • QUIC adds support for Zero-Round-Trip HTTP requests. That is, unlike HTTP over TLS over TCP, which requires multiple data exchanges between the client and server to establish the TLS session before any HTTP request data can be transmitted, QUIC allows HTTP request headers to be sent as part of the TLS handshake when the QUIC connection is being established, significantly reducing initial latency on new connections.

Implementing QUIC for Node.js Core

There is much more to the protocol that I will cover in future blog posts. For now, I want to focus on the effort to implement QUIC for Node.js core.

The implementation started in March of 2019 and has been sponsored jointly by NearForm and Protocol Labs. We are leveraging the fantastic ngtcp2 library to provide the bulk of the low-level implementation. Because QUIC is a reimplementation of much of TCP, the implementation in Node.js is significant and requires more than the typical TCP and HTTP support that is currently implemented in Node.js. Fortunately, most of the complexity will be hidden away from users, as the API examples below illustrate.

The ‘quic’ module

While the new QUIC support is being implemented, we are using a new top-level built-in `quic` module to expose the API. Whether or not this top-level module will be used when the feature lands in Node.js core is still up in the air and will be decided at a later date. Until then, when using the experimental support still in development, `require(‘quic’)` will make the API available for use.

const { createSocket } = require('quic')

The `quic` module exposes a single export: the `createSocket` function. This function is how user code creates instances of `QuicSocket` objects that can be used as either a QUIC Server or Client.

All of the work on QUIC is being performed in a separate GitHub repository that has been forked off of, and developed in parallel to, the main Node.js master branch. If you’d like to play around with the new module or perhaps even contribute to development, grab the source from there. Refer to the Node.js build instructions on how to get started. A warning, however, the implementation is still very much a work in progress — you will encounter bugs.

Creating a QUIC Server

QUIC Servers are `QuicSocket` instances that are configured to wait for new QUIC connections to be initiated by a remote endpoint. This is done by binding to a local UDP port and waiting to receive an initial QUIC packet from a peer. When a QUIC packet is received, the `QuicSocket` will check to see if there is an existing server `QuicSession` object available to handle the packet or will create a new one. Once a server `QuicSession` object is available, the packet will be processed and user-provided callbacks will be invoked. The significant piece here is that all of the details of handling the QUIC protocol are handled internally by Node.js.

const { createSocket } = require('quic')

const { readFileSync } = require('fs')
const key = readFileSync('./key.pem')

const cert = readFileSync('./cert.pem')

const ca = readFileSync('./ca.pem')

const requestCert = true

const alpn = 'echo'
const server = createSocket({

  // Bind to local UDP port 5678

  endpoint: { port: 5678 },

  // Create the default configuration for new

  // QuicServerSession instances

  server: {

    key,

    cert,

    ca,

    requestCert

    alpn 

  }

})

server.listen()

server.on('ready', () => {

  console.log(`QUIC server is listening on ${server.address.port}`)

})

server.on('session', (session) => {

  session.on('stream', (stream) => {

    // Echo server!

    stream.pipe(stream) 

  })


  const stream = session.openStream()

  stream.end('hello from the server')

})

As mentioned previously, support for TLS 1.3 is built into and required by the QUIC protocol. This means that every QUIC connection must have a TLS key and certificate associated with it. What is unique about QUIC relative to traditional TCP based TLS connections is that the TLS context in QUIC is associated with the `QuicSession` rather than the `QuicSocket`. If you’re familiar with the use of `TLSSocket` in Node.js, you’ll definitely understand the distinction here.

Another critical difference with `QuicSocket` (and `QuicSession`) is that unlike the existing `net.Socket` and `tls.TLSSocket` objects exposed by Node.js, neither `QuicSocket` nor `QuicSession` are `Readable` or `Writable` streams. That is, it is not possible to use either object to directly send data to or receive data from the connected peer. For that, the `QuicStream` object must be used.

In the example above, a `QuicSocket` is created and bound to local UDP port 5678. That `QuicSocket` is then told to listen for new QUIC connections to be initiated. Once the `QuicSocket` has started listening, the ready event will be emitted.

When a new QUIC connection has been initiated and a new corresponding server `QuicSession` object has been created, the session event will be emitted. The created `QuicSession` object can be used to listen for new client-initiated `QuicStream“ instances or can be used to create new server-initiated `QuicStream` instances.

One of the more important features of the QUIC protocol is that a client may initiate a new connection with a server without also opening an initial stream, and servers may initiate their own streams without first waiting for an initial stream from the client. This capability opens a significant range of very interesting use cases that are currently not possible with HTTP/1 and HTTP/2 in Node.js core.

Creating a QUIC Client

There is very little difference between a QUIC client and server:

const { createSocket } = require('quic')

const fs = require('fs')

const key = readFileSync('./key.pem')

const cert = readFileSync('./cert.pem')

const ca = readFileSync('./ca.pem')

const requestCert = true

const alpn = 'echo'

const servername = 'localhost'

const socket = createSocket({

  endpoint: { port: 8765 },

  client: {

    key,

    cert,

    ca,

    requestCert

    alpn,

    servername

  }

})


const req = socket.connect({

  address: 'localhost',

  port: 5678,

})


req.on('stream', (stream) => {

  stream.on('data', (chunk) => { /.../ })

  stream.on('end', () => { /.../ })

})


req.on('secure', () => {

  const stream = req.openStream()

  const file = fs.createReadStream(__filename)

  file.pipe(stream)

  stream.on('data', (chunk) => { /.../ })

  stream.on('end', () => { /.../ })

  stream.on('close', () => {

    // Graceful shutdown

    socket.close()

  })

  stream.on('error', (err) => { /.../ })

})

For both server and client, the createSocket() function is used to create the `QuicSocket` instance bound to a local UDP port. For QUIC clients, providing a TLS key and cert is required only if client authentication is being used.

Calling the `connect()` method on the `QuicSocket` will create a new client `QuicSession` object and initiate a new QUIC connection with the server identified by the address and port properties. Initiating this connection will start the TLS 1.3 handshake. Once that handshake is complete, the secure event will be emitted on the client `QuicSession` object, indicating that user code may start using it.

Similarly to the server-side, once the client `QuicSession` object is created, the stream event can be used to listen for new `QuicStream` instances initiated by the server, and the `openStream()` method can be called to initiate a new stream.

Unidirectional & Bidirectional Streams

All `QuicStream` instances are Duplex stream objects, meaning that they implement both the `Readable` and `Writable` stream Node.js APIs. However, in QUIC, every stream can be either Bidirectional or Unidirectional.

A Bidirectional stream is readable and writable in both directions, regardless of whether the stream was initiated by the client or server. A unidirectional stream is readable and writable in only one direction. A unidirectional stream initiated by the client is writable only by the client, and readable only by the server; no data events will ever be emitted on the client. A unidirectional stream initiated by the server is writable only by the server, and readable only by the client; no data events will ever be emitted on the server.

// Create a Bidirectional stream

const stream = req.openStream()


// Create a Unidirectional stream

const stream = req.openStream({ halfOpen: true })

Whenever any stream is initiated by the remote peer, the server `QuicSession` or client `QuicSession` objects will emit a stream event that provides the `QuicStream` object. This object may be inspected to determine both its origin (client or server) and its direction (unidirectional or bidirectional)

session.on('stream', (stream) => {

  if (stream.clientInitiated)

    console.log('client initiated stream')

  if (stream.serverInitiated)

    console.log('server initiated stream')
  if (stream.bidirectional)

    console.log('bidirectional stream')

  if (stream.unidirectional)

    console.log(‘’unidirectional stream')

})

The `Readable` side of a unidirectional `QuicStream` initiated by the local endpoint will always be closed immediately upon creation of the `QuicStream` object, so the data event will never be emitted. Likewise, the `Writable` side of a unidirectional `QuicStream` initiated by the remote endpoint will always be closed immediately upon creation so calls to write() will always fail.

Is that it?

From the examples above, it should be clear that from the user’s perspective, creating and using QUIC is relatively straightforward. While the protocol itself is quite complex under the covers, very little of that complexity bubbles up to the user-facing API. There are a few advanced features and configuration options included in the implementation that are not illustrated in the examples above and use of those will be largely optional in the majority of cases.

Not illustrated in the examples yet is support for HTTP/3. Work to implement the HTTP/3 semantics on top of the basic QUIC protocol implementation is underway and will be covered in a future blog post.

The implementation of the QUIC protocol is far from complete. At the time of writing, the IETF working group is still iterating on the QUIC specification, the third party dependency that we are using within Node.js to implement most of QUIC is also still evolving, and our implementation is far from complete with missing tests, benchmarks, documentation, and examples. However, the work is progressing towards landing as an experimental new feature within the Node.js v14 release line. The hope is for QUIC and HTTP/3 support to become fully supported within Node.js v15. We would love your help! There are features to implement; tests to write; documentation and examples to update, and benchmarks to run! Please reach out if you’re interested in contributing!

(You may also be interested in reading more about QUIC over on the Cloudflare blog)

Giving Thanks

I cannot conclude this post without thanking both NearForm and Protocol Labs for financially sponsoring the time that I have invested in the QUIC implementation. Both companies have a particular interest in how both QUIC and HTTP/3 will evolve both peer-to-peer and traditional Web application development. Once the implementation is closer to completion, I’ll be back with another blog post that illustrates some of the fantastic possible use cases of the QUIC protocol and the advantages of using QUIC versus HTTP/1, HTTP/2, WebSockets, and others.

 

James Snell (@jasnell) is Head of NearForm Research, a team dedicated to researching & developing major new functionality for Node.js in the areas of performance and security, along with advancements in IOT and Machine Learning. James has 20+ years’ experience in the software industry and is a well-known figure in the global Node.js community. He has been an author, co-author, contributor, or editor of several W3C semantic web and IETF internet standards. He is a core contributor to the Node.js project, is a member of the Node.js Technical Steering Committee (TSC), and has served on the Node.js Foundation Board of Directors as the TSC representative.

 

Top