There’s a few magical features of GraphQL that help the language win out over other API standards. This post is written in the context of an early stage startup that develops a social consumer website and mobile app. So, pretty much your cookie-cutter example for API design. There’s many developers much smarter than I who’ll argue the main value of GraphQL comes in federation, but I’m here to make the argument of GraphQL even for small-scale stacks for which organizational complexity isn’t an issue. I’m sure I’ll also come to love federation in a few years. Until then, I’ll keep enjoying these benefits of adopting GraphQL.
Normalized Caching
The world naturally thinks in objects. A house is an object. With its own sub-objects of doors, floors, and ceilings. Humans naturally define common concepts into abstract boxes to label things by. These objects naturally map to human-understandable types in schemas which compose our application. Any complex application refers to a single “thing” multiple times. Normalized caching is essentially a lookup table that allows you to find an object from a single identifier. This is the GraphQL equivalent of being able to identify your house by its address, rather than having to carry a photo of your house around to describe where it is. Defining everything as an object in GraphQL yields enormous benefits for client-side caching.
The benefits of normalized caching vary significantly application-to-application. Certain applications just don’t care about caching, so normalized caching would be useless. I imagine caching isn’t as important for a banking app (not having worked on one) since data integrity and consistent is crucial. The benefit of displaying a result 80ms sooner isn’t worth the cost of that result being stale and inaccurate. Normalized caching is only helpful where caching is helpful.
For the applications in which normalized caching is important, the next question is, how do we make it easy? Designing a client-side normalized cache is possible in any API standard. As long as you have objects, you can build your own cache and design merge behaviors. However, GraphQL, with its insistence on everything being an object, has strong opinions that lend themselves towards normalized caching as the default. I especially love Apollo Client’s approach to caching with strong opinions on caching as the default and extremely granular controls on exactly how that cache operates. You can write your caching behaviors once and trust that they’ll work for any use-case you’ll have for that object type in the future.
Once you accept the premise of cached objects as a default, many doors open up. Optimistic responses become natural for any trivial CRUD operations and those optimistic results can naturally persist to the rest of your application since you already defined the caching behavior once. As a developer, you shouldn’t have to rewrite the behavior for optimistic updates in 17 different places across 3 different screens. An identifier object should just be that object, no matter where it is in the application.
API as a Contract
You can think about an API like a contract between a backend and frontend developer to help with communication. That said, I’m a full-stack developer who usually churns through full features end-to-end in a single sprint. I’ll make the case that thinking about APIs as contracts is more helpful as a contract between your current self and your future self.
APIs are just maintained standards between two different actors. These consistent standards are crucial for applications which require backwards compatibility. In our case, our mobile app. I envy developers who can launch their frontend and trust that every user has it within a day or two. In the world of app development though, we’re stuck worrying about app store reviews and update cycles. Every few weeks I’ll be surprised by a new record for oldest client version that pops up in our error reporting.
Using a strictly defined API schema helps enforce backwards compatibility by making it exhaustively clear when a definition has changed. Traditional REST APIs forgo contracts altogether. tRPC only creates contracts at any given snapshot of the codebase. Of course, these are solvable problems. You can define API specs for REST APIs. You can explicitly write all your tRPC validators in an easy-to-diff doc. These solutions are great! GraphQL solves this problem at its core. There’s no risk of accidentally returning a number when you meant a string, with the unintended downstream consequences. Both the client and the server have the standard that they can operate on and easily validate against any mismatch.
Most of the benefits I’ve illustrated in this section are also valid for a proper OpenAPI setup, which is a part of why I love OpenAPI.
Everything as an Endpoint
The biggest unlock for me when defining business logic on the server-side was that everything in GraphQL can be an endpoint. Both the
Query
and Mutation
are trivially endpoints: you define some logic to accept some user input and respond with some data. On top of that though, each field within an object can also become an endpoint. Combine that with the advantage of everything being an object in GraphQL, and you can now see every single layer of your schema as an endpoint. Everything as an endpoint simplifies API design by allowing fields in GraphQL to act as dynamic, self-contained units.This definition is quite abstract, so I’ll nail it down with a real world example from our application. We’re a photo sharing company, so naturally we have a
Photo
type in our schema. We store a blurHash
for each photo, which is basically just a really tiny and blurry version of the photo used for previews. These blurhashes are generated at processing time and stored in our metadata database, along with the rest of the metadata associated with a photo (such as the photo’s ID, the uploader’s ID, dimensions, and too many other things). Returning that blurHash
field is trivial in any server implementation. User requests photo → server gets metadata from the database → server returns that metadata. However, at one point, one of our services required the base64 implementation of that blurhash.One solution would be to add
blurHashBase64
to our photo metadata. However, that would be redundant with the existing blurHash
(though totally valid if calculating base64 took a significant amount of time). Another solution would be to add a helper function to transform every Photo
as we read it from the database. I’ve run into many database clients which use this approach. However, with the unlock of “everything as an endpoint”, we can think of blurHashBase64
as an endpoint on top of a field. Instead of using a helper function or storing the base64, we instead write a new endpoint resolver for blurHashBase64
. While defining
blurHashBase64
as a resolver has potential performance optimizations, the most important benefit is that a developer never has to think about it again. We don’t need to re-implement blurHashBase64
for every getPhoto
, getPhotos
, getRankedPhotos
queries or any others in the future. Because as long as those return a Photo
, we know that the blurHashBase64
will be resolved on that photo. With the right server implementation, this is a zero-cost abstraction. However, with the wrong server implementation, this can be a rather costly abstraction. One of the major reasons we switched off of AWS's AppSync is because each of these resolvers required some back-and-forth between microservices (or writing in their custom templating language / limited JS environment). Our current backend is implemented using GraphQL Yoga in AWS Lambda which resolves the full request in a single invocation.
A resolver for
blurHashBase64
is a simple example. Once you add in expensive operations (additional network requests) or more complex rules (specific access control based on user role), everything as a resolver becomes significantly more powerful. You can have a field retrieve entirely new objects if requested (perhaps a photos
field on a User
object). You can have a field define and maintain its own access controls. Each field can maintain its own cache behavior. The possibilities are endless! You just need to take the leap to think about everything as an endpoint.Â
Further Learning
- Incredible technical dive into the problems that are solved by a great GraphQL client and the DevEx advantages at scale that can come from GraphQL adoption
- Added 2025-03-31
Â
If you enjoyed reading this and want to follow-up with any thoughts, feel free to email me at nathan@joinswsh.com. Or if you didn’t enjoy reading this and want to know which parts you hated, I’d also love to hear your thoughts!
Â