How Tailcall statically identifies N+1 issues in GraphQL
As a developer working with GraphQL, you're likely familiar with the concept of N+1 issues. If not, you're in for a treat - check out our N+1 guide!
To summarize, they occur when a GraphQL resolver is called multiple times for a single GraphQL request, leading to a large set of requests upstream and overall slower query execution. In this blog post, we'll dive into how Tailcall specifically identifies N+1 issues in GraphQL, and explore the algorithm and data structures used to detect these issues.
High-Level Working
Unlike a traditional GraphQL implementation where the resolvers are written by hand, Tailcall encourages developers to take a configuration-driven approach. This has many benefits, and we have talked about them in our previous blog.
One of the main advantages of not handwriting is the ability to introspect and optimize. Let's take an example of two functions written in Rust, though the problem is evident in all general purpose programming languages.
const BASE_URL: &str = "https://jsonplaceholder.typicode.com"
// Describes a typical Post returned from the /posts API
struct Post {
id: i32,
user_id: i32
title: String,
body: String,
user: Option<User>
}
// Describes a typical User object from the /users API
struct User {
id: i32
name: String,
email: String
}
// Asynchronously retrieves a `User` object by making a GET request using the given `user_id`.
async fn get_user(user_id: i32) -> User {
let response = request(Method::GET, format!("{}/users/{}", BASE_URL, user_id)).await;
// Decode the body as a User
decode(response.body)
}
// Asynchronously fetches and updates posts with user details from the API.
async fn get_posts() -> Vec<Post> {
let response = request(Method::GET, format!("{}/posts", BASE_URL)).await;
// Decode the response into a Vec<Post>
let mut posts = decode(response.body)
// Set the actual user by making an HTTP call
for post in posts {
post.user = Some(get_user(post.user_id).await);
}
posts
}
You might have identified that there are few issues in the implementation above:
- For each post item we end up making a call for the user independently, there is a clear N+1 issue here.
- It's possible that two posts can be written by the same user, and yet we will end up making duplicate calls for the same user.
- All calls to the
/users
API is being made in sequence, even though they can be paralyzed. - There are multiple points of failure which the above code doesn't handle.
Now, yes all of these issues can be solved by better coding practices, using a Result type and using a DataLoader but that's besides the point. The point here is - Semantic analysis of any code is critical to building a robust systems and is only possible with some sort of human intervention typically in the form of code reviews or perhaps using some LLM if you are into that hype. What you can't do is - Write an algorithm to identify the N+1 issue in your GraphQL implementation that 100% accurate.
With a configuration its a completely different story, take the below Tailcall configuration for example:
schema {
query: Query
}
type Query {
posts: [Post]
@http(url: "https://jsonplaceholder.typicode.com/posts")
}
type Post {
id: ID!
userId: ID!
title: String!
body: String!
user: User
@http(
url: "https://jsonplaceholder.typicode.com/users{{.value.userId}}"
)
}
type User {
id: ID
name: String
email: String
}
It's simple, expressive and doesn't expose the guts of how data will be queried, batched, deduped, parsed etc. Sure, configurations take away flexibility from writing anything but in return it liberates you from a ton of such nitty gritties of building a robust software system. The above configuration file can be parsed, validated and semantically analyzed accurately to identify issues such as N+1 very precisely using the check command as follows: