Caching GraphQL queries without stale data is less about if you can do it, and more about how you structure your application to make it an afterthought.

Let’s say you’ve got a GraphQL API serving blog posts. A client app needs to display a list of posts, and then when a user clicks on one, show the full details of that specific post.

query GetBlogPosts {
  posts {
    id
    title
    author {
      name
    }
  }
}

query GetPostDetails($postId: ID!) {
  post(id: $postId) {
    id
    title
    body
    comments {
      id
      text
      author {
        name
      }
    }
  }
}

If a user fetches GetBlogPosts and then GetPostDetails for post abc-123, you’d want that second query to be fast, ideally without hitting the network again. You also don’t want the user to see an old version of the blog post if the content has been updated on the server.

The core problem is that GraphQL, by design, is flexible. Each query can fetch a unique shape of data. Traditional HTTP caching, based on URLs, breaks down because GET /graphql?query={posts} and GET /graphql?query={post(id:"abc-123")} might both map to the same endpoint but represent entirely different data.

The solution lies in normalizing your data and using a client-side cache that understands relationships. Libraries like Apollo Client or Relay are built for this. They don’t just cache raw query results; they normalize the data into a flat, object-like structure, keyed by type and ID.

When you fetch GetBlogPosts, Apollo Client might store it internally like this:

{
  "ROOT_QUERY": {
    "posts": [
      {
        "__ref": "Post:1"
      },
      {
        "__ref": "Post:2"
      }
    ]
  },
  "Post:1": {
    "__typename": "Post",
    "id": "1",
    "title": "My First Post",
    "author": {
      "__ref": "Author:john-doe"
    }
  },
  "Author:john-doe": {
    "__typename": "Author",
    "name": "John Doe"
  }
  // ... other posts and authors
}

Notice how posts is an array of references (__ref) to individual Post objects, and each Post object contains a reference to its Author. This is normalization.

Now, when you request GetPostDetails for postId: "1", Apollo Client first checks its normalized cache. It finds Post:1 and Author:john-doe already present. It can then reconstruct the GetPostDetails response purely from the cache without a network request.

{
  "post": {
    "__typename": "Post",
    "id": "1",
    "title": "My First Post",
    "body": "...", // This might be missing if GetBlogPosts didn't fetch it
    "comments": [
      // ... references to comment objects
    ]
  }
}

If body or comments were missing from the initial GetBlogPosts query, Apollo Client would then make a targeted network request only for the missing fields of Post:1. This is called field-level fetching and is a key benefit of GraphQL.

The staleness problem is handled by cache invalidation strategies. The most common is garbage collection – if a normalized object is no longer referenced by any query result in the cache, it’s removed. But that doesn’t help with stale data.

For staleness, you typically use time-based expiration (e.g., a Post object is considered stale after 5 minutes) or, more powerfully, write-through caching with optimistic updates and mutations. When a mutation to update a post is sent, Apollo Client can immediately update the cache optimistically, before the server even confirms the change. Then, when the server response comes back, it can either confirm the update or revert the optimistic change. This makes data feel fresh.

The critical part is understanding how your cache is structured. If you fetch a list of posts, and then fetch a single post by ID, the client library should be able to stitch those together. If it can’t, it’s usually because the id field isn’t correctly defined in your schema, or the client library isn’t configured to recognize the id as the unique identifier for that type.

The most common pitfall is thinking you need to cache full query payloads. Instead, focus on normalizing your data by __typename and id, and let the client library manage the relationships. This is how you get cache hits for individual objects and avoid redundant network requests, while still enabling sophisticated invalidation.

The next challenge you’ll face is managing the cache when your schema evolves, particularly with breaking changes to types or fields.

Want structured learning?

Take the full Caching-strategies course →