The joys and pains of working with GraphQL

Only what you need

It's been just around six months since I've started working with GraphQL. If you're not familiar with the protocol, then graphql.org is a good start. In a nutshell, it tries to improve on the REST protocol by not asking for all the data for a given entity, but "only what you need." Let's say you need to display a blog's title, author, summary, and date, but not the last edit, author, or the full content of a post. In GraphQL, you would issue a query like this

query getPostData()  {
  title
  author
  summary
  date
}

and that query would be issued to your GraphQL server at mydomain.com/graphql or wherever you've chosen to mount your GraphQL server. This is one of the stark differences between REST and GraphQL – how your API services requests.

In a REST, you would issue a request to a GET request to an endpoint like mydomain.com/api/posts. If you want to create a new post via an API endpoint, you might have another endpoint mydomain.com/api/posts. This pattern grows into what we know as "CRUD" for "Create Read Update Delete" because you need to use the correct HTTP verb to map to the action you would like to execute. Requests that change or create data are known as muatations in GraphQL.

The ups and downs

CRUD and GraphQL applications have to provide the same functionality via code paths in the end, i.e. I still have to write CRUD queries in GraphQL. One difference in the workflow with GraphQL is the lack of route maintenance. For one resource like a blog's posts, you might see something like:

// Post REST routes
router.GET("/posts", ctrl.GetAllPosts)
router.GET("/posts/:id", ctrl.GetPostById)
router.POST("/posts", ctrl.CreatePost)
router.PATCH("/posts/:id", ctrl.PartialPostUpdateById)
router.DELETE("/posts/:id", ctrl.DeletePostById)

This quickly becomes burdensome when you add new resources to the application; however, this same burden is also a feature. The application is self-documenting and it's easy to understand the entrypoints into an application that's being exposed via HTTP/HTTPS.

In a large, complex GraphQL applicaton tracking down how clients query the API requires more energy than you would like. There's a lot of engineering discipline required to keep your GraphQL API from turning itself into a REST-like API as a product changes. In the first GraphQL query above, getPostData, if we remove summary from our front-end code – it's also easy to forget we should do that for our query so that we don't over-request data.

Performance Implications

You might think GraphQL query reusage to be a great idea but I'll try to demonstrate why you should not reuse queries. Let's say we're over-engineering a blog. Your product manager asks you to add image thumbails to your blog. Easy enough. Your co-worker wants to get "back to his roots" and decides that this is the job of a Haskell microservice. A few months later, he has this shiny new service behind AWS's API Gateway and Lambda. You don't know why because your company uses Google Cloud Platform, but you shrug it off and update the query to call the imagePreviewUrl resolver (a lot like a controller in REST-land.) he setup:

query getPostData()  {
  title
  author
  summary
  date
  imagePreviewUrl
}

Your co-worker didn't understand that DNS lookups, network hops, and cold-starts are all problems for applications who expect their responses in less than 200 milliseconds. Because your company is the "#1 Overengineered Blog" in the blog space, you have a widget that thousands of other blogs include on theirs. getPostData was used in this widget and now a lot of people are mad that their pageloads are being affected by your widget.

You might think this is hyperbole, and you would be right, but I've experienced this exact scenario in different shapes and forms. Lessons learned: don't over-request data and understand the performance implications of each field you add to your query.

A REST API would run may run into this same problem, depending on how you implement it. In both REST and GraphQL, separating imagePreviewUrl from the performant query into another endpoint or query is one option:

REST:

router.GET("/posts/:id/image-preview-url", ctrl.GetPostImagePreviewUrl)

GraphQL

query getPostData($postId: Int)  {
  post(postId: $postId) {
    imagePreviewUrl
  }
}

Mental Gynamstics Required

Reasoning about the graph is easily the hardest part of working with GraphQL. It's not a linear mode of thinking and requires you to hold more of the application surface in your head to effectively work on the graph.

Let's keep running with our blog example – very meta of me – and talk about field resolution. First, let's start with a minimal JavaScript GraphQL server. Our entry point into the graph will be through retrieving posts by id's. Simple enough, right?

const { ApolloServer, gql } = require("apollo-server");
const db = require("./db");

const typeDefs = gql`
  type Author {
    name: String!
  }

  type Post {
    title: String!
    author: Author!
  }

  type Query {
    post(id: String!): Post!
  }
`;

const resolvers = {
  Query: {
    post: (root, args, context) => {
      const postId = args && args.id;
      try {
        const result = context.db.getPostById(postId);
        return {
          ...result,
          author: {
            id: "1",
            name: "baz",
          },
        };
      } catch (err) {
        console.error(err);
        return null;
      }
    },
  },
  Author: {
    name: (root, args, context) => {
      return "foobar";
    },
  },
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: { db },
});

server.listen().then(({ url }) => {
  console.log(`Server ready at ${url}`);
});

The gotcha's

Reading through the example, you probably can see the confusing bits. There are two ways for GrahpQL to resolve an author's name, and that's completely valid in GraphQL land.

If you're like me and thought, "The post query returns everything that the query needs, why would GraphQL need to do anything else?", then you might also be like me and be surprised to know that if you were to execute this query:

query {
  post(id: "1") {
    title
    author {
      name
    }
  }
}

The response for name would be: foobar and not baz as returned above. GraphQL calls the Author.name resolver because one of the neat features of GraphQL is that we could have omitted Author.name inside of the post resolver and GraphQL would have done all of that work for us. If we pre-populate this piece of data, we need to handle that in our resolver:

  name: (root, args, context) => {
    if (root.name) {
      return root.name;
    }
      return "foobar";
  },

There are many cases where you might already have a piece of data available from a database query and you would like to "return early," as we've done here but GraphQL makes your pay a tax of sorts for the flexibility it offers. This tax, in large code bases, can grow and become cumbersome. The non-linear nature of dealing with graphs has left me wanting some sort of GUI representation of the graph that lights up as a request moves through each resolver.

Was REST so bad that we need to make understanding how our requests are routed more difficult?

Caching

Caching has been covered in-depth throughout the net, but I think it bears mentioning here. The story for GraphQL caching, even in 2021, still feels incomplete. If you're using Apollo Server, you can define cache-control settings in your schema for individual fields, but I've found this not to be very ergonomic.

Final thoughts

Reading through this post you might think, "This guy absolutely hates GraphQL." That would be incorrect. When you're at the scale of Facebook or even where I work, Meetup, the amount of data that might be saved over a year's time is meaningful.

Would I ever use it on a side project or recommend it to a startup? Probably not. Would I make the same choice if I was the CTO of a large, high-traffic tech company? Probably.

GraphQL still feels like a young technology and it is maturing. For front-end developers, I think it provides a great experience, but I feel that the problem of "stitching together data" was never solved – it was just moved from the front-end to the backend. When you think about that, it sounds like a really great idea: less network traffic, less data transferred, easier development workflow for front-end developers. In practice, the "edges"1 are quite rough.


1 - A bad GraphQL joke.

© Nick Olinger 2021