How we solved GraphQL’s biggest flaw

Nicolas Keller
Equify | {tech:’blog’}

--

At Equify we are obsessed with performance. When big companies set out the standard for what a fast app feels like, you have no choice but to invest time and resources in optimizing your app or you risk frustrating your users.

There are quite a few levers you can pull to improve the “feeling” of your app, and you can classify those in two categories:

  • Things you can do to make your app feel faster: optimistic responses, loaders, and other UX tricks that inform or even hide the fact that something is loading.
  • Things you can do to actually make your app faster: better hardware, cache, optimized algorithms, and so on.

In this article, I’m going to focus on the latter and walk you through the journey of solving the biggest flaw of GraphQL architectures.

Your GraphQL backend is not optimized

The first thing we are going to do is to dig a little bit into how GraphQL resolves queries, and why it is so terribly inefficient for the majority of apps. Let’s start things off with a simple GraphQL query:

…and the most basic resolvers you can think of:

Notice how I use DataLoader in my example. If you are not familiar with what DataLoader is, consider giving this article a quick read.

And now that we have our test setup let’s have a look at what tracing looks like:

The problem here is fairly obvious, the 3 database calls are run sequentially which makes the request slower than it could have been. The Article.comments resolver only needs the article's id to resolve, but we wait for the entire article to be fetched first.

We will see how to solve this parallelization issue next, but first, we need to address a second issue we face with this naive approach: over-fetching. To illustrate what this means, let’s only query the comment’s ids:

Now tracing looks like this:

We end up doing 3 queries to the database where one would have been enough. This is a classic example where we end up executing useless database calls that slow down the entire request and puts unnecessary stress on the infrastructure.

Re-thinking resolvers

A better approach is to fetch data at field-level in your resolvers:

You might be worry that when querying the title and the content we would end up fetching the article twice, but thanks to DataLoader queries are de-duplicated and cached precisely to avoid this issue.

Let’s run our first query once again and look at tracing results:

You can clearly see that some parallelization is going on and that we reduced the response time significantly.

With this pattern, you also get lazy-loading for free, which means that you do not over-fetch like in our previous example. Let’s run the second query once again to illustrate:

As you can see we only executed 1 database call instead of 3, and significantly reduce the overall response time.

The only downside to this pattern is clarity and repetitiveness. Which is why we created and open-sourced GraphQL-Proxy. GraphQL-Proxy is simply a thin wrapper around this pattern that allows you to write clean code and separate concerns in your application.

We developed GraphQL-Proxy with simplicity and efficiency in mind (less than 200 LoC), our work was greatly inspired by DataLoader. We wanted this package to be agnostic of any library, have clear Errors for developers, and have 100% test coverage. Your feedback is always welcome!

Using GraphQL-Proxy

Start by installing GraphQL-Proxy using npm:

npm i gql-proxy

In the solution we described, the Query.article resolver returns only the id and no database calls are executed. With GraphQL-Proxy we use proxies instead, proxies are just thin wrappers around your ids.

The Article.id now receives an Article proxy and returns the id of that proxy instead of simply returning the id it receives. And because this behavior is exactly what the default resolver does in GraphQL, we can omit the id resolver altogether:

Remember that doing new Article(id) does not actually do anything, no database calls, no checks. Just like when we simply returned the id but wrapped in a class.

Fetching data

We are now going to implement the title and content resolvers. We need to tell our proxy how to fetch data from our database using DataLoader. The first thing you want to do is adding your loader to your GraphQL context:

And when instantiating an article, simply forward the loaders from the GraphQL context to the proxy context:

And that is it, the title and content resolvers are up and running!

It looks magical but the mechanisms behind are trivial. First, we did not write any resolvers, which is equivalent to writing the default resolver by hand:

article.id simply returns the value we passed when instantiating the proxy without any database call.

We did not specify anything for article.title and article.content so the proxy falls-back to getting those values from the database using the loaders object we passed to the context.

This works because we followed the following convention:

  • When instantiating the article we passed a loaders object to the context.
  • The loaders object has a key equal to entityType of createProxy({ entityType }) with a DataLoader as value.
  • The DataLoader .load(id) function returns an object with the keys title and content.

Remember that data-fetching occurs at field-level, this means that article.title and article.content are promises:

Adding getters

In this section, we will implement the Article.comments resolver. Just like article.title is a Promise that resolves to the title of the article, we expect article.comments to be a Promise that resolves to an array of... Comment proxies of course!

So the first thing to do is to create the Comment proxy class:

We can now add the articleCommentIdsLoader and commentLoader loaders to the GraphQL context:

Remember that when we instantiate an Article we pass it the loaders object. So our article proxy has access to the articleCommentIdsLoader through its context, we just need to write the getter for article.comments. Getters are defined when creating the proxy class:

And that is it, all endpoints are now working.

Here is a recap of the necessary code to make this work, note how much clearer it looks compared to the raw solution we had at the beginning:

We hope this article will help you with your endeavors, do not hesitate to clap if you found it useful, share it with people you love, and send us your feedback and comments below! ❤️

--

--