Skip to content

dhalbrook/undici-examples

Repository files navigation

Fetching faster with Undici

Way back in 2022, in the before times, Node.js had no native fetch support. It was added in the Node 18 release, and the library underpinning this was called "Undici" (latin for eleven). In the past three years, Node.js has continued to develop and improve, and behind the scenes so has Undici.

Anyone who has written a non-trivial project using fetch on the server, be it in middleware or underpinning isomorphic API calls on the UI layer, knows that often enough data fetching becomes a performance bottleneck. Most often we simply call "fetch" and hope for the best, because it's not readily apparent that other options are available.

The fetch spec, as implemented by Node.js, uses a globally scoped dispatcher backed by Undici, and this dispatcher underpins all fetch calls. It works well and works as expected, which is a testament to sensible defaults. But the Node.js fetch implementation also allows for a custom dispatcher to be passed to a fetch call, like this:

const dispatcher = ???

const response = await fetch(`https://catfact.ninja/fact`, {
    dispatcher
} as RequestInit);

The dispatcher you provide here can be as powerful as you need it to be, and Undici has plenty of options to tune it to your needs. Alternatively, you can override the global dispatcher if you want to customize the way every fetch call in your app works by doing the following:

setGlobalDispatcher(dispatcher);

Pooling

Were you aware that Undici can pool your fetch connections? Doing so allows you to gate the total number of connections to your origin host and minimize the overhead of TLS negotiation. Here’s an example of creating a pooled dispatcher with up to five connections:

/**
 * Let's add connection pooling and limit the pool to 5 connections
 *
 * See https://github.com/nodejs/undici/blob/main/docs/docs/api/Pool.md
 */
function createDispatcher(): Dispatcher {
    return new Agent({
        // time out after 10 seconds
        connectTimeout: 10000,
        factory(origin: string | URL, opts: Agent.Options): Dispatcher {
            return new Pool(origin, {
                ...opts,
                // use up to 5 connections in the pool
                connections: 5
            })
        }
    });
}

The example demonstrates just a small subset of the options available to configure the pool.

HTTP/2

Does your API host support HTTP/2? If so, you may be leaving a lot of performance on the table by not enabling this flag. Undici supports HTTP/2 by enabling the “allowH2” flag. Here is the previous example with this enabled. Another new feature called “clientTtl” can gracefully shut down pool members after a specified time. This makes it easy to minimize HTTP/2 GOAWAY responses:

/**
 * Let's add h2 support and cycle the clients every minute to avoid GOAWAY frames
 *
 * See https://github.com/nodejs/undici/blob/main/docs/docs/api/Client.md
 */
function createDispatcher(): Dispatcher {
    return new Agent({
       factory(origin: string | URL, opts: Agent.Options): Dispatcher {
            return new Pool(origin, {
                ...opts,
                // use up to 5 connections in the pool
                connections: 5,
                // allow for H2 connections if the origin supports it
                allowH2: true,
                // gracefully close down the client after 1 minute
                clientTtl: 60 * 1000
            })
        }
    });
}

DNS Caching and Retry

Undici also has built-in interceptors that can be chained. The “dns” interceptor caches DNS lookups, while the “retry” interceptor can retry network errors. The “cache” interceptor caches responses.

/**
 * Let's add retry, caching, and dns caching
 *
 * See https://github.com/nodejs/undici/blob/main/docs/docs/api/Dispatcher.md#pre-built-interceptors
 */
import { interceptors } from "undici";

function createDispatcher(): Dispatcher {
    return new Agent({
    }).compose(
        // add dns caching
        interceptors.dns({
            affinity: 4
        }),
        // add retry capability
        interceptors.retry({
            maxRetries: 3,
        }),
        // cache responses
        interceptors.cache({})
    );
}

Stats

Finally, Undici can share stats on connection pools at runtime, like so:

PoolStats {
    connected: 3,
        free: 2,
        pending: 0,
        queued: 0,
        running: 1,
        size: 1
}

Here is an example of printing out the stats every ten seconds. This allows you to monitor the state of your connection pool in an APM or simply in the logs:

/**
 * Finally, let's log out the connection pool stats every ten seconds
 *
 * See https://github.com/nodejs/undici/blob/main/docs/docs/api/PoolStats.md
 */

return new Agent({
   factory(origin: string | URL, opts: Agent.Options): Dispatcher {
        const pool = new Pool(origin, {});
        setInterval(() => {
            // connect me to your telemetry system!
            console.log(pool.stats)
        }, 10000)
        return pool
    }
})

Wrap-up

I hope this gives you some ideas of the possibilities of tuning Undici so that you and your user base can fetch faster.

Making your Node.js GraphQL calls more resilient

These days, making server-side GraphQL calls from a web application is a common practice. The popularity of GraphQL as a language has increased, and its richness of expression in a single call has led it to be a natural fit for fetching entire domain object graphs in one go. As long as you keep the query size and complexity under control, it can be a great solution for rendering your UI views.

When this is done in a Node.js application (either directly or via a Node-based framework like Next.js), the GraphQL client generally delegates its request to the native fetch client to execute. In modern versions of Node.js this native fetch client is called Undici (https://github.com/nodejs/undici).

The problem with GraphQL calls

By default, Undici assumes only GET and HEAD requests are idempotent (see https://undici.nodejs.org/\#/docs/api/Dispatcher?id=parameter-dispatchoptions). This makes sense for REST APIs. In this context of Undici, idempotency refers to the ability to safely retry the request if it fails. Having idempotent calls improves resilience overall, and especially so if you’re using pipelining, where multiple requests are sent over the same connection concurrently.

Often enough GraphQL requests, be they queries or mutations, are made using POST. This is because GraphQL operation bodies are prone to being too large to safely serialize into the URL, especially if multiple fragments are included. However, because they don’t actually mutate anything, semantically these queries are equivalent to a GET, and thus are safe to mark as idempotent. In other words, even though they use POST, they won’t cause any side effects if they are retried.

OK, that’s nice, but how do we make this happen?

The first step is to implement your own custom Undici agent. The previous article has examples, and it can be just a few lines of code.

Once your GraphQL client is using a custom Agent, there is a simple approach to telling Undici that you’re executing a GraphQL query: use a marker header. This header can be anything you want, but in the case of my examples, I’m using: “x-graphql-query” with a value of “true”. You can provide a simple overload of “useQuery” for Apollo Client, or other strategies for including this header. The important thing is that it’s only included for queries, not for mutations.

Here is a very simple example using the “graphql-request” library:

const response = await graphQLClient.request(query, {},
 {
   // pass this through as a marker that this is a query and not a mutation
   "x-graphql-query": "true",
 },
);

When it gets to the Undici layer, you can pick it up and correlate it in the interceptor, like so:

const graphqlQueryHeader = "x-graphql-query";

/**
* This is an example interceptor to mark graphql requests as idempotent.
*/
function graphQLQueryInterceptor(): Dispatcher.DispatcherComposeInterceptor {
 return (dispatch) => {
   return function InterceptedDispatch(options, handler) {
     const { headers } = options;
     // look for the marker header that indicates this is a GraphQL query
     if (
       headers &&
       graphqlQueryHeader in headers &&
       headers[graphqlQueryHeader] === "true"
     ) {
       //clean up the marker header
       delete headers[graphqlQueryHeader];
       // mark graphql queries as idempotent so they can be retried and pipelined
       options.idempotent = true;
     }
     return dispatch(options, handler);
   };
 };
}

The final step is to make sure your Undici dispatcher is using the interceptor, like so:

import { Agent, Dispatcher, interceptors } from "undici";

function createGraphQLDispatcher(): Dispatcher {
 return new Agent()
   .compose([graphQLQueryInterceptor(), interceptors.retry()])
}

And there you have it! From this point on, your GraphQL requests will be able to be pipelined, as well as automatically be retried in the case of network errors.

Happy coding!

About

This is a sample repository demonstrating ways of tuning the Undici library in a Next.js app.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published