New Algorithm to Solve the N+1 Problem

While implementing Cosmo Router, an open-source replacement for Apollo Router, we encountered the problem of maintaining our code to solve the N+1 problem. The router implementation for GraphQL federated services relies heavily on the ability to group nested GraphQL queries to reduce the number of subgraph queries.

To solve this problem, we developed a new algorithm that solves the N+1 problem more efficiently and easier to maintain than our previous solution, which was based on the DataLoader pattern commonly used in the GraphQL community. Instead of resolving by depth first, we load data by width first, which allows us to reduce parallelism from O(N^2) to O(1) and improve performance by up to 5x while reducing code complexity.

If you're interested in checking out the code, you can find it at GitHub.

I also gave a talk on this topic at GraphQL Conf 2023, which you can watch here: https://youtu.be/vWQYI5fNytM

Problem N+1

The N+1 problem is a common problem in GraphQL that occurs when you have a list of elements and you need to retrieve additional data for each element in the list. Let's look at an example to illustrate this problem:

First we define four subgraphs:

# Products
type Query {
  topProducts: [Product!]
}

type Product @key(fields: "upc") {
  upc: String!
  name: String!
}

# Inventory
type Product @key(fields: "upc") {
  upc: String! @external
  stock: Int!
}

# Accounts
type User @key(fields: "id") {
   id: ID!
   name: String
}

# Reviews
type Review @key(fields: "id") {
   id: ID!
   body: String
   author: User
}

type User @key(fields: "id") {
   id: ID! @external
}

type Product @key(fields: "upc") {
   upc: String! @external
   reviews: [Review]
}

We then define a query that gets the top products and their reviews:

query {
 topProducts { # returns a list of products from the Products subgraph
    name
    stock # returns the stock from the Inventory subgraph
    reviews { # returns a list of reviews from the Reviews subgraph
      body
      author { # returns the author from the Accounts subgraph
        name
      }
    }
  }
}

Where did the name problem N+1 come from? +1 refers to the first request to receive topProducts from the Products subgraph. N refers to the number of additional queries required to retrieve associated data for each product. Let's walk through the steps of resolving this request to see what this means in practice.

Let's say the field topProducts returns 3 products. We have to make 3 additional queries to get the inventory for each product from the Inventory subgraph. We also have to make 3 additional queries to get reviews for each product in the Reviews subgraph. We just assume that each product has 3 reviews. For each review, we must make 1 additional query to get the author from the Accounts subgraph. So in total we have to make 1 + 3 + 3 + 3 * 3 = 16 requests to resolve this request.

If you look closely, you will see that the number of queries grows exponentially with the depth of the query and the number of items in the list, where nested lists exacerbate the problem, as in our example where each product has a list of reviews. What if every review had a list of comments, and every comment had a list of likes? You see where this leads.

DataLoader Template

The DataLoader pattern is a general solution for solving the N+1 problem in GraphQL. It is based on the idea of ​​grouping queries in lists to reduce the number of queries. What's great about GraphQL is that it allows us to group queries by default. With a REST API, you need to explicitly implement grouping endpoints, but with GraphQL, you can easily combine multiple requests into a single request.

In addition, the Federation makes grouping even easier because the well-known field _entities to extract “Entities” maintains a list of views as input. This means that retrieving entities by their keys allows you to group queries by default. All we need to do is make sure that we actually group queries in lists together to use this feature, which is what the DataLoader pattern does. Using the DataLoader pattern we can reduce the number of queries from 16 to 4, one query to load topProductsone to download stock information, one to download reviews, and one to download authors.

Let's look at an example to illustrate how the DataLoader pattern works:

If we add the necessary key fields, that is, the fields that are used to retrieve entities by their keys, our GraphQL query above looks like this:

query {
 topProducts {
    __typename
    upc
    name
    stock
    reviews {
      __typename
      id
      body
      author {
        __typename
        id
        name
      }
    }
  }
}

The first step is to get topProducts from the Products subgraph. This is what the request looks like:

query {
 topProducts {
    __typename
    upc
    name
  }
}

Let's say the Products subgraph returns 3 products, here's what our response might look like:

{
  "data": {
    "topProducts": [
      {
        "__typename": "Product",
        "upc": "1",
        "name": "Table"
      },
      {
        "__typename": "Product",
        "upc": "2",
        "name": "Couch"
      },
      {
        "__typename": "Product",
        "upc": "3",
        "name": "Chair"
      }
    ]
  }
}

Now we will go through all the products and download the associated data for each product. Let's do this for the first product.

This is what our “input” looks like:

{
  "__typename": "Product",
  "upc": "1",
  "name": "Table"
}

Now we need to get the inventory for this product from the Inventory subgraph.

query {
  _entities(representations: [
    {
      __typename: "Product",
      upc: "1"
    }
  ]) {
    __typename
    ... on Product {
      stock
    }
  }
}

Although this query works, it does not allow us to group the other two friend products. Let's change the query to allow grouping by using variables instead of embedding views directly in the query:

query($representations: [_Any!]!) {
  _entities(representations: $representations) {
    __typename
    ... on Product {
      stock
    }
  }
}

This request will be exactly the same for all products. The only difference will be in the value of the variable $representations. This allows us to implement a simplified version of the DataLoader pattern.

Here's what it might look like: This is a function that takes a query and a list of views as input. The function will then hash the request and merge all views for the same request into one request. If there are duplicate views, we will remove the duplicates before creating the variable object. It will make a query for each unique request and return “ungrouped” results to each caller.

async function load(query: string, representations: any[]) {
  const hash = hashQuery(query);
  return loadUnique(hash, query, representations);
}
async function loadUnique(hash: string, query: string, representations: any[]) {
  const variables = {
    representations: uniqueRepresentations(representations),
  };
  const result = await fetch(query, {
    method: "POST",
    body: JSON.stringify({ query, variables }),
  });
  const data = await result.json();
  return unbatchEntities(data);
}

Great! We implemented a simplified version of the DataLoader template and used it to load inventory data for each product. Now let's also download the reviews.

query($representations: [_Any!]!) {
  _entities(representations: $representations) {
    __typename
    ... on Product {
      reviews {
        __typename
        id
        body
        author {
          __typename
          id
        }
      }
    }
  }
}

Run this through our function load, and we will receive reviews for each item. Let's assume that each product has 3 reviews. Here's what the answer might look like:

{
  "data": {
    "_entities": [
      {
        "reviews": [
          {
            "__typename": "Review",
            "id": "1",
            "body": "Love it!",
            "author": {
              "__typename": "User",
              "id": "1"
            }
          },
          {
            "__typename": "Review",
            "id": "2",
            "body": "Hate it!",
            "author": {
              "__typename": "User",
              "id": "2"
            }
          },
          {
            "__typename": "Review",
            "id": "3",
            "body": "Meh!",
            "author": {
              "__typename": "User",
              "id": "3"
            }
          }
        ]
      }
    ]
  }
}

Next, we go to each review and load the author from the Accounts subgraph.

This is what our input for the first review looks like:

{
  "__typename": "Review",
  "id": "1",
  "body": "Love it!",
  "author": {
    "__typename": "User",
    "id": "1"
  }
}

Now we need to find the author of this review from the Accounts subgraph.

query {
  _entities(representations: [
    {
      __typename: "User",
      id: "1"
    }
  ]) {
    __typename
    ... on User {
      name
    }
  }
}

Again we do this for all friends and merge requests for the same request. Finally, we are done loading all the data and can output the JSON response according to the GraphQL specification and return it to the client.

Problem with DataLoader template

At first glance, the DataLoader pattern seems like a great answer to solve the N+1 problem. After all, it significantly reduces the number of requests from 16 to 4, so what could be the problem?

There are two main problems with the DataLoader pattern.

First, it makes a trade-off between performance and code complexity. Although we reduce the number of queries, we significantly increase the complexity of our code. While this is not visible in a single-threaded environment such as Node.js, multi-threaded environments such as Go or Rust will have difficulty using this pattern effectively. I'll come back to this later.

Second, while the DataLoader pattern reduces the number of queries, it exponentially increases the parallelism of our code. With each level of nesting and each element in the list, we exponentially increase the number of concurrent queries that are executed in the “DataLoader”.

This comes down to a fundamental flaw in the DataLoader pattern. In order to be able to group requests from multiple friend fields, all friend fields must be “attached” to the package at the same time. This means that we have to go through all the friend fields at the same time and make them call the function load DataLoader at the same time. Once all the friend fields have joined this packet, they are blocked until the packet is resolved.

But it gets even worse with nested lists. For our root list, we have to branch out into 3 parallel operations to load the data for all 3 products using the DataLoader pattern. So we have parallelism by 4 if we also consider the root field. But since we are loading not only inventory information but also reviews for each product, we have to add 3 more parallel operations for each product. With this we are at concurrency level 7. But we are not done yet. As we discussed, we assume that we will get back 3 reviews for each product. To load the author for each review, we therefore need to branch out into 9 parallel operations for each review. With this we are at concurrency level 16.

To illustrate the issue, here's a visualization from the report. The numbers are slightly different, but the problem remains the same.

DataLoader Concurrency

DataLoader Concurrency

As you can see, grouping depends on the ability to call the function at the same time load for all friend fields.

As I mentioned earlier, not only is there an additional overhead to concurrency, but for non-single-threaded environments like Go, Rust, Java, etc., the need to resolve friend fields at the same time presents another challenge.

Federation, but also other GraphQL composite styles, allow you to define dependencies between fields. This means that you can define that the field @requires another field that must be resolved before it can be resolved on its own. For example, you might have a user object that contains a field id. Using the field id you can get the user's street and house number. Once you have these two, you can get the user's full address, which depends on the other two fields.

In the worst case scenario, the following may happen. You have a list of user-friend objects. For each of them, you load the street and house number as a group. You do it all at the same time. Once this is done, you must merge the results with the original user objects. Then you go through each user object again, simultaneously, and load the full address as a group.

By adding parallelism to this operation, we must synchronize the results, we must ensure that we do not read or write the same objects at the same time, or we must copy all objects when we branch into parallel operations, and then ensure that when we we combine results, we do not write to the same objects at the same time.

In any case, we will have to copy multiple objects, synchronize parallel operations, or even use locks and mutexes to prevent simultaneous reading and writing of the same objects. All this can not only complicate our code, but also negatively affect performance.

If you have experience with highly parallel code, you may know that debugging parallel code is very difficult. If the debugger jumps between threads, it is difficult to follow the progress of the code. This is where printing to the console again becomes a useful debugging tool.

However, it would be great to get rid of all this parallelism. What if we could write simple synchronous code, free of locks, mutexes, threads, and other concurrency primitives? And that's exactly what we did!

New Algorithm to Solve N+1 Problem – Breadth-First Loading

Most, if not all GraphQL servers allow depth-first fields. This means that they resolve a field and all of its subfields before allowing the next friend field. So in a list of elements they resolve one element at a time exhaustively. If we're talking about trees, they resolve the leftmost branch until they reach a leaf node, then they resolve the next branch and so on until they reach the rightmost branch.

This seems natural because this is how we would “print” JSON into a buffer. You open the first curly brace, print the first field, open another curly brace to start an object, print the contents of the object, close the curly brace, and so on.

So how can we eliminate the need to resolve friend fields at the same time to enable grouping? The solution is to split the resolver into two parts. We first go through the “Query Plan” breadth first, load all the data we need from the subgraphs, and concatenate the results into a single JSON object. Second, we traverse the combined JSON object depth-first and print the JSON response into a buffer according to the GraphQL request from the client.

Since this may be a little abstract, let's go through the example step by step.

First we load topProducts from the Products subgraph, as we did before. The answer still looks like this:

{
  "data": {
    "topProducts": [
      {
        "__typename": "Product",
        "upc": "1",
        "name": "Table"
      },
      {
        "__typename": "Product",
        "upc": "2",
        "name": "Couch"
      },
      {
        "__typename": "Product",
        "upc": "3",
        "name": "Chair"
      }
    ]
  }
}

Then we go “breadth first” into the product list. This gives us what we call “elements”, a list of objects.

[
  {
    "__typename": "Product",
    "upc": "1",
    "name": "Table"
  },
  {
    "__typename": "Product",
    "upc": "2",
    "name": "Couch"
  },
  {
    "__typename": "Product",
    "upc": "3",
    "name": "Chair"
  }
]

We now have a list of products, so we can download inventory information for all products at once. Let's remember the query we need to load inventory information:

query($representations: [_Any!]!) {
  _entities(representations: $representations) {
    __typename
    ... on Product {
      stock
    }
  }
}

We can simply call this operation with our list of elements as input, excluding the field of course name. Additionally, you can see that we no longer need the DataLoader template. We don't need parallelism to group requests for friend fields. All we do is call the Inventory subgraph with a list of product views as input.

The result of this operation is exactly the same as before:

{
  "data": {
    "_entities": [
      {
        "stock": 10
      },
      {
        "stock": 5
      },
      {
        "stock": 2
      }
    ]
  }
}

Let's combine it with the elements we got from the Products subgraph:

[
  {
    "__typename": "Product",
    "upc": "1",
    "name": "Table",
    "stock": 10
  },
  {
    "__typename": "Product",
    "upc": "2",
    "name": "Couch",
    "stock": 5
  },
  {
    "__typename": "Product",
    "upc": "3",
    "name": "Chair",
    "stock": 2
  }
]

The merging algorithm is quite simple. Based on the product's position in the list, we merge the inventory information from the response _entities into a product object.

We repeat this process for reviews. Let's assume we have 3 reviews for each product. This will return an array of entities with 3 reviews each.

[
  {
    "reviews": [
      {
        "__typename": "Review",
        "id": "1",
        "body": "Love it!",
        "author": {
          "__typename": "User",
          "id": "1"
        }
      },
      {
        "__typename": "Review",
        "id": "2",
        "body": "Hate it!",
        "author": {
          "__typename": "User",
          "id": "2"
        }
      },
      {
        "__typename": "Review",
        "id": "3",
        "body": "Meh!",
        "author": {
          "__typename": "User",
          "id": "3"
        }
      }
    ]
  },
  {
    "reviews": [
      {
        "__typename": "Review",
        "id": "4",
        "body": "Love it!",
        "author": {
          "__typename": "User",
          "id": "4"
        }
      },
      {
        "__typename": "Review",
        "id": "5",
        "body": "Hate it!",
        "author": {
          "__typename": "User",
          "id": "5"
        }
      },
      {
        "__typename": "Review",
        "id": "6",
        "body": "Meh!",
        "author": {
          "__typename": "User",
          "id": "6"
        }
      }
    ]
  },
  {
    "reviews": [
      {
        "__typename": "Review",
        "id": "7",
        "body": "Love it!",
        "author": {
          "__typename": "User",
          "id": "7"
        }
      },
      {
        "__typename": "Review",
        "id": "8",
        "body": "Hate it!",
        "author": {
          "__typename": "User",
          "id": "8"
        }
      },
      {
        "__typename": "Review",
        "id": "9",
        "body": "Meh!",
        "author": {
          "__typename": "User",
          "id": "9"
        }
      }
    ]
  }
]

Let's do the same thing we did before and combine reviews into each of the product elements.

[
  {
    "__typename": "Product",
    "upc": "1",
    "name": "Table",
    "stock": 10,
    "reviews": [
      {
        "__typename": "Review",
        "id": "1",
        "body": "Love it!",
        "author": {
          "__typename": "User",
          "id": "1"
        }
      },
      {
        "__typename": "Review",
        "id": "2",
        "body": "Hate it!",
        "author": {
          "__typename": "User",
          "id": "2"
        }
      },
      {
        "__typename": "Review",
        "id": "3",
        "body": "Meh!",
        "author": {
          "__typename": "User",
          "id": "3"
        }
      }
    ]
  },
  {
    "__typename": "Product",
    "upc": "2",
    "name": "Couch",
    "stock": 5,
    "reviews": [
      {
        "__typename": "Review",
        "id": "4",
        "body": "Love it!",
        "author": {
          "__typename": "User",
          "id": "4"
        }
      },
      {
        "__typename": "Review",
        "id": "5",
        "body": "Hate it!",
        "author": {
          "__typename": "User",
          "id": "5"
        }
      },
      {
        "__typename": "Review",
        "id": "6",
        "body": "Meh!",
        "author": {
          "__typename": "User",
          "id": "6"
        }
      }
    ]
  },
  {
    "__typename": "Product",
    "upc": "3",
    "name": "Chair",
    "stock": 2,
    "reviews": [
      {
        "__typename": "Review",
        "id": "7",
        "body": "Love it!",
        "author": {
          "__typename": "User",
          "id": "7"
        }
      },
      {
        "__typename": "Review",
        "id": "8",
        "body": "Hate it!",
        "author": {
          "__typename": "User",
          "id": "8"
        }
      },
      {
        "__typename": "Review",
        "id": "9",
        "body": "Meh!",
        "author": {
          "__typename": "User",
          "id": "9"
        }
      }
    ]
  }
]

Great! We have obtained products, stock information and reviews for each product. Now how do we resolve the field author for each review?

The first step is to go “breadth-first” into the list of reviews. Our “elements” will then look like this:

[
  {
    "__typename": "Review",
    "id": "1",
    "body": "Love it!",
    "author": {
      "__typename": "User",
      "id": "1"
    }
  },
  {
    "__typename": "Review",
    "id": "2",
    "body": "Hate it!",
    "author": {
      "__typename": "User",
      "id": "2"
    }
  },
  {
    "__typename": "Review",
    "id": "3",
    "body": "Meh!",
    "author": {
      "__typename": "User",
      "id": "3"
    }
  },
  {
    "__typename": "Review",
    "id": "4",
    "body": "Love it!",
    "author": {
      "__typename": "User",
      "id": "4"
    }
  },
  {
    "__typename": "Review",
    "id": "5",
    "body": "Hate it!",
    "author": {
      "__typename": "User",
      "id": "5"
    }
  },
  {
    "__typename": "Review",
    "id": "6",
    "body": "Meh!",
    "author": {
      "__typename": "User",
      "id": "6"
    }
  },
  {
    "__typename": "Review",
    "id": "7",
    "body": "Love it!",
    "author": {
      "__typename": "User",
      "id": "7"
    }
  },
  {
    "__typename": "Review",
    "id": "8",
    "body": "Hate it!",
    "author": {
      "__typename": "User",
      "id": "8"
    }
  },
  {
    "__typename": "Review",
    "id": "9",
    "body": "Meh!",
    "author": {
      "__typename": "User",
      "id": "9"
    }
  }
]

It's just a list of reviews, great. Then we move on to authorsagain using breadth-first traversal, which brings us to a list of users.

[
  {
    "__typename": "User",
    "id": "1"
  },
  {
    "__typename": "User",
    "id": "2"
  },
  {
    "__typename": "User",
    "id": "3"
  },
  {
    "__typename": "User",
    "id": "4"
  },
  {
    "__typename": "User",
    "id": "5"
  },
  {
    "__typename": "User",
    "id": "6"
  },
  {
    "__typename": "User",
    "id": "7"
  },
  {
    "__typename": "User",
    "id": "8"
  },
  {
    "__typename": "User",
    "id": "9"
  }
]

Now we can load the field name for all users at once, without parallelism, just with one request to the Accounts subgraph.

query($representations: [_Any!]!) {
  _entities(representations: $representations) {
    __typename
    ... on User {
      name
    }
  }
}

The result might look like this:

{
  "data": {
    "_entities": [
      {
        "name": "Alice"
      },
      {
        "name": "Bob"
      },
      {
        "name": "Carol"
      },
      {
        "name": "Dave"
      },
      {
        "name": "Eve"
      },
      {
        "name": "Frank"
      },
      {
        "name": "Grace"
      },
      {
        "name": "Heidi"
      },
      {
        "name": "Ivan"
      }
    ]
  }
}

Let's combine it with a list of users:

[
  {
    "__typename": "User",
    "id": "1",
    "name": "Alice"
  },
  {
    "__typename": "User",
    "id": "2",
    "name": "Bob"
  },
  {
    "__typename": "User",
    "id": "3",
    "name": "Carol"
  },
  {
    "__typename": "User",
    "id": "4",
    "name": "Dave"
  },
  {
    "__typename": "User",
    "id": "5",
    "name": "Eve"
  },
  {
    "__typename": "User",
    "id": "6",
    "name": "Frank"
  },
  {
    "__typename": "User",
    "id": "7",
    "name": "Grace"
  },
  {
    "__typename": "User",
    "id": "8",
    "name": "Heidi"
  },
  {
    "__typename": "User",
    "id": "9",
    "name": "Ivan"
  }
]

Now all we need to do is climb up the tree and merge the resolved elements into their parent objects. There is one important thing to note here. When we go to the list of elements within an element, we need to note which child element indices belong to which parent element. It's possible that we have lists of unequal length within a list of elements, so we'd be out of order if we didn't keep track of the indexes of the child elements.

So if we go up the tree and group users into reviews, our elements will look like this:

[
  {
    "__typename": "Review",
    "id": "1",
    "body": "Love it!",
    "author": {
      "__typename": "User",
      "id": "1",
      "name": "Alice"
    }
  },
  {
    "__typename": "Review",
    "id": "2",
    "body": "Hate it!",
    "author": {
      "__typename": "User",
      "id": "2",
      "name": "Bob"
    }
  },
  {
    "__typename": "Review",
    "id": "3",
    "body": "Meh!",
    "author": {
      "__typename": "User",
      "id": "3",
      "name": "Carol"
    }
  },
  {
    "__typename": "Review",
    "id": "4",
    "body": "Love it!",
    "author": {
      "__typename": "User",
      "id": "4",
      "name": "Dave"
    }
  },
  {
    "__typename": "Review",
    "id": "5",
    "body": "Hate it!",
    "author": {
      "__typename": "User",
      "id": "5",
      "name": "Eve"
    }
  },
  {
    "__typename": "Review",
    "id": "6",
    "body": "Meh!",
    "author": {
      "__typename": "User",
      "id": "6",
      "name": "Frank"
    }
  },
  {
    "__typename": "Review",
    "id": "7",
    "body": "Love it!",
    "author": {
      "__typename": "User",
      "id": "7",
      "name": "Grace"
    }
  },
  {
    "__typename": "Review",
    "id": "8",
    "body": "Hate it!",
    "author": {
      "__typename": "User",
      "id": "8",
      "name": "Heidi"
    }
  },
  {
    "__typename": "Review",
    "id": "9",
    "body": "Meh!",
    "author": {
      "__typename": "User",
      "id": "9",
      "name": "Ivan"
    }
  }
]

There are nine reviews in total with information about the author in each review. Finally, we go through the tree one last time and combine the reviews with the products. Our resulting object will look like this:

[
  {
    "__typename": "Product",
    "upc": "1",
    "name": "Table",
    "stock": 10,
    "reviews": [
      {
        "__typename": "Review",
        "id": "1",
        "body": "Love it!",
        "author": {
          "__typename": "User",
          "id": "1",
          "name": "Alice"
        }
      },
      {
        "__typename": "Review",
        "id": "2",
        "body": "Hate it!",
        "author": {
          "__typename": "User",
          "id": "2",
          "name": "Bob"
        }
      },
      {
        "__typename": "Review",
        "id": "3",
        "body": "Meh!",
        "author": {
          "__typename": "User",
          "id": "3",
          "name": "Carol"
        }
      }
    ]
  },
  {
    "__typename": "Product",
    "upc": "2",
    "name": "Couch",
    "stock": 5,
    "reviews": [
      {
        "__typename": "Review",
        "id": "4",
        "body": "Love it!",
        "author": {
          "__typename": "User",
          "id": "4",
          "name": "Dave"
        }
      },
      {
        "__typename": "Review",
        "id": "5",
        "body": "Hate it!",
        "author": {
          "__typename": "User",
          "id": "5",
          "name": "Eve"
        }
      },
      {
        "__typename": "Review",
        "id": "6",
        "body": "Meh!",
        "author": {
          "__typename": "User",
          "id": "6",
          "name": "Frank"
        }
      }
    ]
  },
  {
    "__typename": "Product",
    "upc": "3",
    "name": "Chair",
    "stock": 2,
    "reviews": [
      {
        "__typename": "Review",
        "id": "7",
        "body": "Love it!",
        "author": {
          "__typename": "User",
          "id": "7",
          "name": "Grace"
        }
      },
      {
        "__typename": "Review",
        "id": "8",
        "body": "Hate it!",
        "author": {
          "__typename": "User",
          "id": "8",
          "name": "Heidi"
        }
      },
      {
        "__typename": "Review",
        "id": "9",
        "body": "Meh!",
        "author": {
          "__typename": "User",
          "id": "9",
          "name": "Ivan"
        }
      }
    ]
  }
]

And it's all! We have now loaded all the data we need to generate the final JSON response. Since we may have loaded unnecessary data, such as required fields, and we are not accounting for aliases, this is a necessary step to ensure that our JSON response is in the exact form that the client requested.

This was a very low-level view of the algorithm. Let's step back a little and look at this algorithm from a higher level.

Batch processing in width

Batch processing in width

You'll notice two things in this visualization. It shows 3 threads instead of one. This is because we parallelize stock information and reviews. This is possible because they are at the same level in the tree and only depend on the products. The second thing you'll notice is that other than parallelizing the two operations above, we're not using parallelism at all.

The key difference of allowing fields to be width-first rather than depth-first is that we can always automatically group requests for friend fields, since we always have a list of elements (friends) instead of visiting each element (friend) branch behind the branch (in depth) in parallel.

Speaking of concurrency and parallelization, we made another important observation about parallel fetching at the same level in the tree, just as we did for stock information and reviews.

During our research, we initially tried to parallelize the two packages and immediately combine the results into elements. This turned out to be a bad idea, since it would require us to synchronize the join, for example using a mutex. This made the code more complex and had a negative impact on performance.

Instead, we found a better solution. When we render the input data for each of the packages, we only read from the elements, so it is safe to do this in parallel. Instead of immediately concatenating the results into elements, we pre-allocate one result set per batch to temporarily store the results for each batch. We then wait until all parallelized batches have completed and merge the results from the result sets into items on the main thread. This way we don't need to synchronize at all and can avoid blocking entirely, although we use parallelism to receive packets.

Benefits of loading data wide

Now that we've looked at the algorithm in detail, let's talk about the benefits of loading data breadth first.

  • We got rid of our DataLoader implementation

  • We have no parallelism, except for parallel fetching at the same level in the tree

  • We don't need to synchronize parallel operations

  • We don't need to use locks or mutexes

We can again easily debug our code, and the entire implementation is much easier to understand and maintain. In fact, our initial implementation was not completely complete, but we were able to quickly fix and extend it because the code was easy to understand.

As a side effect, although this was not our main goal, we have seen performance improvements of up to 5 times over our previous implementation using the DataLoader pattern. In fact, the performance gap increases with the number of elements in lists and the level of nesting, which perfectly illustrates the problem with the DataLoader pattern.

Conclusion

The DataLoader pattern is a great solution to solve the N+1 problem. This is a great solution to improve the N+1 problem, but as we have seen, it has its limitations and can become a bottleneck.

Since we're building a router for federated GraphQL, it's critical to keep our code maintainable and with low performance overhead. The ability to reduce parallelism in our code is a huge benefit, especially when we can reduce parallelism from O(N^2) to O(1).

So does this really only affect GraphQL federated routers, or GraphQL gateways, or even GraphQL servers in general?

I think we should all be aware of the tradeoffs we make when using the DataLoader pattern. There are frameworks like Grafast that offer completely new approaches to resolving GraphQL operations. This is a good time to question the status quo and see if we can take this knowledge and apply it to our own GraphQL server and frameworks.

Should GraphQL frameworks consider implementing breadth-first data loading? Should you consider implementing batch loading as a first class citizen in your GraphQL server? I don't know the answer, but I think it's right to ask these questions. I'd be interested to hear your thoughts on this.

Once again, the implementation is open and available on GitHub.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *