<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kay Schecker</title>
    <description>The latest articles on DEV Community by Kay Schecker (@kayschecker).</description>
    <link>https://dev.to/kayschecker</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3987654%2F01c2905d-355a-452f-96c4-0d3f1bb2e9f6.jpeg</url>
      <title>DEV Community: Kay Schecker</title>
      <link>https://dev.to/kayschecker</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kayschecker"/>
    <language>en</language>
    <item>
      <title>Resolver-level GraphQL security is already too late</title>
      <dc:creator>Kay Schecker</dc:creator>
      <pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate>
      <link>https://dev.to/graphpilot/resolver-level-graphql-security-is-already-too-late-1coo</link>
      <guid>https://dev.to/graphpilot/resolver-level-graphql-security-is-already-too-late-1coo</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkjj61hiy7w4b4ac03em8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkjj61hiy7w4b4ac03em8.png" alt="Resolver-level GraphQL security is already too late" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"We added a depth limit in the resolver, so we're covered." It's one of the first things a team reaches for the moment they realize a GraphQL endpoint accepts arbitrarily shaped queries. It feels like the responsible move, and it's not wrong, exactly. It's just placed at the wrong layer, and that placement is the whole problem. By the time a resolver runs, the request has already crossed your perimeter, been parsed, been planned, and started touching your data. The check fires, but the expensive part already happened. As a &lt;em&gt;first&lt;/em&gt; line of defense, the resolver is already too late.&lt;/p&gt;

&lt;p&gt;This post is about why your first line of GraphQL defense can't live inside your application, what does still belong there, and what changes when the boundary moves to the edge, in front of the origin.&lt;/p&gt;

&lt;h2&gt;
  
  
  GraphQL breaks the assumptions perimeter security is built on
&lt;/h2&gt;

&lt;p&gt;Traditional API security leans heavily on the HTTP envelope. A WAF or a CDN looks at the method, the URL, the headers, and makes a decision: this path is rate limited, that one is blocked, this method isn't allowed here. The shape of the request tells you a lot about its intent.&lt;/p&gt;

&lt;p&gt;GraphQL throws that away. You have one endpoint, usually &lt;code&gt;/graphql&lt;/code&gt;, and almost everything goes through &lt;code&gt;POST&lt;/code&gt; with the real request sitting in the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /graphql
Content-Type: application/json

{
  "query": "
    {
      user(id: 1) {
        friends(first: 100) {
          friends(first: 100) {
            friends(first: 100) {
              name
            }
          }
        }
      }
    }
  "
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To anything that doesn't parse GraphQL, that request is indistinguishable from a harmless one. Same method, same URL, same headers. A WAF sees a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/graphql&lt;/code&gt; and shrugs. The intent, and the danger, is entirely in the body. So just like with caching, before you can secure anything you have to teach the boundary to look &lt;em&gt;inside&lt;/em&gt; the request. And the moment you accept that, the natural question is where that inspection should happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attacks that actually matter
&lt;/h2&gt;

&lt;p&gt;GraphQL's flexibility is the product. It's also the attack surface. A handful of attack classes show up again and again, and in every case the query body is what reveals them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Complexity and depth denial-of-service.&lt;/strong&gt; GraphQL lets a client ask for deeply nested, recursive relationships in a single query. &lt;code&gt;user → friends → friends → friends&lt;/code&gt; and so on. Each level multiplies the work. A short, innocent-looking query can expand into millions of resolver calls and a database meltdown. There's no oversized payload to catch; the request is tiny. The cost is in how it unfolds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aliasing and batching abuse.&lt;/strong&gt; GraphQL aliases let a client request the same field many times under different names in one operation. Combine that with query batching and an attacker can pack hundreds of attempts into a single HTTP request: brute-forcing a coupon or gift-card code through an aliased lookup, enumerating records by guessing IDs, or amplifying one expensive query into hundreds. Per-request rate limiting counts that as &lt;em&gt;one&lt;/em&gt; request and waves it through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Introspection leakage.&lt;/strong&gt; GraphQL ships with introspection, a query that returns the entire schema. Wonderful in development, a gift to an attacker in production: every type, every field, every mutation, handed over as a map of where to probe next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Broken field-level authorization.&lt;/strong&gt; This is the quiet one. The query is cheap, well formed, and perfectly legitimate in shape. It just asks for a field the viewer shouldn't see. Authorization in GraphQL is per field and per context, and there is no framework default that gets it right for you. It's where most real GraphQL data exposure actually lives.&lt;/p&gt;

&lt;p&gt;Notice what these have in common: none of them is visible from the outside, and none of them is a malformed request. They're valid GraphQL. You can only catch them by understanding the query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why resolvers are the wrong place to catch them
&lt;/h2&gt;

&lt;p&gt;So you reach for resolver code, because that's where you understand the query best. Here's why it disappoints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's too late.&lt;/strong&gt; A resolver runs during execution. To reach it, the request already crossed your network boundary, got parsed, got planned, and in many cases already started resolving sibling fields. A depth limit enforced in a resolver is a smoke detector that goes off after the fire reaches the kitchen. For a complexity DoS, the work you were trying to prevent is the work that got you to the resolver in the first place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's too scattered.&lt;/strong&gt; Authorization and limits implemented field by field, service by service, drift. One team checks a permission, another forgets, a third checks a slightly different one. The same rule exists in twelve places in twelve shapes, and the gap is wherever someone was in a hurry. Security that depends on every resolver author remembering the same thing is security with a long tail of holes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's not centrally observable.&lt;/strong&gt; When the defense is smeared across the resolver graph, there's no single place that can answer "how many queries did we reject in the last hour, and why." You can't tune, alert on, or audit a policy that doesn't exist as a policy anywhere.&lt;/p&gt;

&lt;p&gt;And underneath all three sits the genuinely hard part: to reject a query safely, you have to estimate what it will &lt;em&gt;cost before you run it&lt;/em&gt;. That's not a resolver's job. By the time a resolver could weigh in, you've already started paying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it gets nasty
&lt;/h2&gt;

&lt;p&gt;This is the part that doesn't fit on a feature slide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static cost vs. real cost.&lt;/strong&gt; You estimate a query's cost by walking its shape and assigning weights, before execution. But the true cost depends on data: how many friends that user actually has, how big that list really is. Estimate too loosely and you wave through expensive queries; too strictly and you reject legitimate ones. Tuning that boundary is ongoing work, not a constant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting by cost, not count.&lt;/strong&gt; "100 requests per minute" is meaningless when one request can be a thousand times heavier than another. Useful GraphQL rate limiting is budget-based: you spend from a cost allowance, and a heavy query spends more. That means you need the cost estimate &lt;em&gt;and&lt;/em&gt; a shared, fast accounting of spend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization that's schema-aware.&lt;/strong&gt; Field-level auth has to understand types, interfaces, and how a field is reached. The same field may be allowed via one path and not another. This is real design, and parts of it (deep business rules) genuinely do belong in your application. The point isn't that the edge does all auth; it's that the edge enforces the structural, declarative parts consistently so resolvers aren't the only thing standing between a query and your data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persisted operations as an allowlist.&lt;/strong&gt; One strong mitigation is to stop accepting arbitrary queries at all: register known operations ahead of time and reject anything not on the list. It closes most of the surface above in one move. It also constrains your clients and needs a deploy-time workflow, so it's a decision, not a free switch. (It pairs neatly with &lt;a href="https://graphpilot.io/#optimize" rel="noopener noreferrer"&gt;persisted queries&lt;/a&gt; on the performance side.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is insurmountable. Together, it's why "we'll just validate queries in our gateway" turns into a quarter of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the edge is the right layer
&lt;/h2&gt;

&lt;p&gt;Put all of this in front of the origin and the picture gets simpler in exactly the ways that matter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fewmgzc5vtvrjtv6nxbx7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fewmgzc5vtvrjtv6nxbx7.png" alt="Same query, two layers. At the edge it's priced and rejected before the origin does any work;&lt;br /&gt;in a resolver it's caught only after the cost has been paid." width="799" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's early.&lt;/strong&gt; The query is parsed and priced before the origin does any work. A rejected query costs the edge a cheap inspection and costs your database nothing. That's the difference between absorbing an attack and forwarding it to the thing you most need to protect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's one place.&lt;/strong&gt; Depth and complexity budgets, cost-based rate limits, introspection control, and the structural parts of authorization are declared once, in front of every service, instead of reimplemented in each. One policy, consistently applied, is something you can actually reason about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's observable.&lt;/strong&gt; A single chokepoint is where you can count rejections, alert on spikes, and audit what your policy did. You can't tune a defense you can't see.&lt;/p&gt;

&lt;p&gt;The honest tradeoff: this logic now runs on the hot path of every request, so it has to be cheap. Spend too much per request inspecting it and you've traded an availability problem for a latency one. Getting that balance right, fast inspection that doesn't become its own bottleneck, is exactly the engineering that makes edge security hard to do well.&lt;/p&gt;

&lt;h2&gt;
  
  
  What good looks like
&lt;/h2&gt;

&lt;p&gt;Whether you build this or buy it, a serious GraphQL security layer has a few non-negotiables worth insisting on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://graphpilot.io/#schema-driven" rel="noopener noreferrer"&gt;Schema-driven policy&lt;/a&gt;.&lt;/strong&gt; Limits, scopes, and field rules declared alongside the schema, so they're reviewable, versioned, and diffable, not buried in scattered code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://graphpilot.io/#security" rel="noopener noreferrer"&gt;Depth and complexity budgets enforced before execution&lt;/a&gt;.&lt;/strong&gt; A query is priced and accepted or rejected up front, not discovered to be expensive halfway through running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://graphpilot.io/#security" rel="noopener noreferrer"&gt;Cost-based rate limiting&lt;/a&gt;.&lt;/strong&gt; Spend from a budget, where a heavy query costs more, instead of counting all requests as equal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://graphpilot.io/#security" rel="noopener noreferrer"&gt;Introspection control and field-level authorization&lt;/a&gt;&lt;/strong&gt; applied by default, so production doesn't hand out its own map and personalized fields aren't exposed by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://graphpilot.io/#observability" rel="noopener noreferrer"&gt;Observability for what you blocked&lt;/a&gt;.&lt;/strong&gt; Rejected queries, reasons, and rates, visible. "It feels secure" is not a metric.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;A depth limit in a resolver isn't wrong, it's just standing in the wrong room. GraphQL's dangerous requests look identical from the outside and only reveal themselves inside the query, and by the time a resolver reads that query, the request is already past the boundary you wanted to defend. The controls that actually hold, cost estimation, budget-based limits, introspection control, consistent field authorization, want to live in front of the origin, declared once and watched in one place.&lt;/p&gt;

&lt;p&gt;That's part of what we're building &lt;strong&gt;GraphPilot&lt;/strong&gt; to do. It's not open yet; if you run GraphQL in production and want early access, or want to shape it as a design partner, you can &lt;a href="https://graphpilot.io/register" rel="noopener noreferrer"&gt;join the waitlist&lt;/a&gt;. We'd genuinely rather hear which of these attacks is keeping &lt;em&gt;you&lt;/em&gt; up than guess, so if you want to compare notes, jump into the comments wherever you found this or &lt;a href="https://graphpilot.io/contact" rel="noopener noreferrer"&gt;contact us&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>graphql</category>
      <category>security</category>
      <category>dos</category>
      <category>authorization</category>
    </item>
    <item>
      <title>Why you can't just put a CDN in front of GraphQL</title>
      <dc:creator>Kay Schecker</dc:creator>
      <pubDate>Tue, 16 Jun 2026 16:01:19 +0000</pubDate>
      <link>https://dev.to/graphpilot/why-you-cant-just-put-a-cdn-in-front-of-graphql-1b34</link>
      <guid>https://dev.to/graphpilot/why-you-cant-just-put-a-cdn-in-front-of-graphql-1b34</guid>
      <description>&lt;p&gt;It's one of the most obvious ideas the moment a GraphQL API starts buckling under load: "Can't we just put a CDN in front of it?" The question comes up on almost every team scaling GraphQL: in architecture reviews, in Stack Overflow threads, in Slack. It sounds reasonable: CDNs made REST fast and cheap to scale, so surely the same trick works one layer up. Then you try it, and within an hour you understand why GraphQL caching is its own product category instead of a config flag. That's exactly why it needs its own tooling and processes. And that's what this post is about.&lt;/p&gt;

&lt;p&gt;The short version: GraphQL breaks almost every assumption that HTTP caching is built on. And for good reason, because that same flexibility is what makes GraphQL so powerful in the first place. Some of the resulting problems you can solve in an afternoon. One of them, invalidation, is genuinely hard, and it's where most home-grown attempts quietly fall apart. But it is solvable: there are good tools that let you master these difficulties.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first challenge: one endpoint, one verb, everything in the body
&lt;/h2&gt;

&lt;p&gt;HTTP caching is keyed on the request. A CDN looks at the method and the URL, maybe a header or two, and decides whether it has seen this exact thing before. &lt;code&gt;GET /users/1&lt;/code&gt; is trivially cacheable: the URL &lt;em&gt;is&lt;/em&gt; the cache key, and &lt;code&gt;Cache-Control&lt;/code&gt; tells the CDN how long to keep it.&lt;/p&gt;

&lt;p&gt;GraphQL throws that model out. You have a single endpoint, usually &lt;code&gt;/graphql&lt;/code&gt;, and almost everything goes through &lt;code&gt;POST&lt;/code&gt; with the actual query sitting in the request body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /graphql
Content-Type: application/json

{
  "query": "
    {
      user(id: 1) {
        name
        posts {
          title
        }
      }
    }
  "
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To a stock CDN, every request to your API looks identical: same method, same URL. It has no idea that one body asks for a user's name and the next triggers a 50-table aggregation. It can't tell two requests apart, so it can't safely cache either. The thing that made REST cacheable, namely a meaningful URL, is gone.&lt;/p&gt;

&lt;p&gt;So before you can cache anything, you have to teach the cache to look &lt;em&gt;inside&lt;/em&gt; the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making GraphQL cacheable at all (the easy 80%)
&lt;/h2&gt;

&lt;p&gt;This part is mostly solved, and if you only need read caching you can get a long way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Normalize the query into a stable key.&lt;/strong&gt; Two requests that ask for the same data can look different on the wire: different whitespace, reordered fields, variables inline versus separated. You parse the query, canonicalize it (sort fields, extract variables, strip noise), and hash the result. Now logically identical requests collapse to the same cache key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get it past the CDN layer.&lt;/strong&gt; Because bodies and &lt;code&gt;POST&lt;/code&gt; are awkward to cache, you lean on &lt;code&gt;GET&lt;/code&gt; requests plus &lt;a href="https://graphpilot.io/#optimize" rel="noopener noreferrer"&gt;&lt;em&gt;persisted queries&lt;/em&gt;&lt;/a&gt;: the client sends a hash of a known query instead of the full text, and that hash becomes part of a cacheable URL. Automatic Persisted Queries (APQ) are the common flavor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assign TTLs from the schema.&lt;/strong&gt; Not all data ages the same way. A product catalog can be stale for minutes; an account balance cannot. You annotate types and fields with a max age and let the cache apply per-type, per-field TTLs instead of one blunt number for the whole response.&lt;/p&gt;

&lt;p&gt;Do those three things and you have a working read cache. This is the part vendors demo and the part you can build yourself. It is &lt;em&gt;not&lt;/em&gt; the part that hurts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: invalidation
&lt;/h2&gt;

&lt;p&gt;TTL-only caching forces an unpleasant trade-off. Short TTLs mean low hit rates; you're barely caching. Long TTLs mean you serve stale data, and "the dashboard showed the old number for five minutes" is the kind of bug that can erode trust fast. What you actually want is to cache aggressively &lt;em&gt;and&lt;/em&gt; drop a cached response the instant the underlying data changes.&lt;/p&gt;

&lt;p&gt;Here's where GraphQL's flexibility turns against you. In REST, a write maps cleanly to a resource: &lt;code&gt;PUT /users/1&lt;/code&gt; invalidates the cache entry for &lt;code&gt;/users/1&lt;/code&gt;. There's a URL to purge. In GraphQL there's no URL, and worse: a single mutation can invalidate &lt;strong&gt;fragments of many unrelated cached responses&lt;/strong&gt;. Change one user's name and you've potentially staled every cached query that embedded that user: a profile page, a comment list, a search result, an admin table, each cached under a different key.&lt;/p&gt;

&lt;p&gt;You can't purge by URL because there isn't one. So you invalidate by &lt;em&gt;what's inside&lt;/em&gt; the responses instead.&lt;/p&gt;

&lt;p&gt;The standard approach is &lt;strong&gt;surrogate keys&lt;/strong&gt; (also called cache tags). When you cache a response, you walk it and record every entity it contains, typically &lt;code&gt;__typename&lt;/code&gt; + &lt;code&gt;id&lt;/code&gt;, e.g. &lt;code&gt;User:1&lt;/code&gt;, &lt;code&gt;Post:42&lt;/code&gt;. Those become tags attached to the cached entry. When a mutation comes through, you figure out which entities it touched and purge every cached entry tagged with them. &lt;code&gt;User:1&lt;/code&gt; changes → drop everything tagged &lt;code&gt;User:1&lt;/code&gt;, regardless of which query produced it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5wkbbm3kdb5c0zc0t8jh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5wkbbm3kdb5c0zc0t8jh.png" alt="Surrogate-key invalidation: one mutation names User:1; the proxy purges every cached response tagged with it" width="800" height="299"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Conceptually clean. The trouble is in everything the clean version ignores.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it gets nasty
&lt;/h2&gt;

&lt;p&gt;This is the part nobody puts in the marketing copy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lists and pagination.&lt;/strong&gt; A mutation &lt;em&gt;creates&lt;/em&gt; a new post. The new entity has an ID that doesn't appear in any cached response yet, so tag-based purging can't find the lists it should now appear in. "Invalidate the entity" doesn't help when the problem is "a collection should have grown." You end up needing coarser, list-level invalidation, which claws back some of the hit rate you fought for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutations that don't return the changed entity.&lt;/strong&gt; Your tagging logic learns which entities to purge by inspecting payloads. A mutation that returns just &lt;code&gt;{ success: true }&lt;/code&gt; tells you nothing about what it changed. Now you're guessing, or you're forcing schema conventions on every mutation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derived and aggregate fields.&lt;/strong&gt; &lt;code&gt;commentCount&lt;/code&gt;, computed totals, or "is this in stock" depend on entities that aren't directly named in the response. The thing that changed and the field that's now wrong don't share an ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization and personalization.&lt;/strong&gt; The same query returns different data per viewer. Cache it naively and you leak one user's data to another. That's a security bug, not a performance one. Cache it correctly and your key space explodes by user, gutting your hit rate. Deciding what's safely shared versus per-viewer is a design problem with no universal answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial responses and errors.&lt;/strong&gt; GraphQL can return &lt;code&gt;data&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;errors&lt;/code&gt; in the same &lt;code&gt;200&lt;/code&gt; response. Do you cache a partial result? A response that succeeded for three fields and failed for one? There's no HTTP status to lean on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema changes.&lt;/strong&gt; Deploy a new schema and yesterday's cached responses may no longer match today's shape. Your cache has to know when its assumptions expired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The good news: none of these is insurmountable. But together they're why "we'll just add caching to our GraphQL gateway" turns into a quarter of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the edge makes it harder, and why it's still worth it
&lt;/h2&gt;

&lt;p&gt;Everything above is true even with a single, central cache. Push it to the edge, meaning many points of presence close to users, and you inherit a distributed-systems problem on top of the GraphQL problem.&lt;/p&gt;

&lt;p&gt;Now your cache state is spread across many locations. A purge isn't an instant local delete; it has to &lt;em&gt;propagate&lt;/em&gt;, and during that window different users can get different answers. A write in one region races against a read in another. The index that maps entities to cached responses has to be consistent enough to be trustworthy and fast enough to consult on the hot path. Because at the edge you can't burn real compute per request without giving back the latency you came for.&lt;/p&gt;

&lt;p&gt;So why bother? Because when it works, you move read traffic off your origin entirely and serve it from near the user, and your database stops being the thing that falls over at peak. The payoff is real. It's just that the gap between "naive TTL cache" and "correct, invalidating, edge-distributed cache" is exactly where the engineering lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What good looks like
&lt;/h2&gt;

&lt;p&gt;Whether you build this or buy it: these are the properties worth insisting on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://graphpilot.io/#schema-driven" rel="noopener noreferrer"&gt;Schema-driven cache configuration&lt;/a&gt;.&lt;/strong&gt; Cacheability, TTLs, and scoping declared alongside the schema, so reviewable, versioned, and diffable, not scattered across ad-hoc rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://graphpilot.io/#edge-cache" rel="noopener noreferrer"&gt;Entity-level surrogate keys with a real purge API&lt;/a&gt;.&lt;/strong&gt; Tagging plus a way to say "drop everything touching &lt;code&gt;User:1&lt;/code&gt;" on demand, not just on a timer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://graphpilot.io/#security" rel="noopener noreferrer"&gt;Explicit auth scoping&lt;/a&gt;.&lt;/strong&gt; A deliberate decision about what's shared versus per-viewer, enforced by default. Personalized data should never be cacheable by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://graphpilot.io/#observability" rel="noopener noreferrer"&gt;Observability&lt;/a&gt;.&lt;/strong&gt; Hit rate, staleness, and purge lag, visible. You cannot tune an invalidation strategy you can't measure, and "it feels faster" is not a metric.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;GraphQL caching is easy to start and hard to finish. The cacheable-key part is a weekend; correct invalidation across personalized, paginated, edge-distributed responses is why this becomes its own product category and not a feature you flip on in passing. If you've shipped a GraphQL API and watched your origin strain under read traffic, you've felt the pull toward the edge, and probably the pain of doing it right.&lt;/p&gt;

&lt;p&gt;That's exactly the problem we're building &lt;strong&gt;GraphPilot&lt;/strong&gt; to solve. It's not open yet. I'd genuinely rather hear which of the edge cases above is hurting &lt;em&gt;you&lt;/em&gt; than guess, so drop a comment below: which one bites hardest in your setup? And if you run GraphQL in production and want early access, you can &lt;a href="https://graphpilot.io/register" rel="noopener noreferrer"&gt;join the waitlist&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>graphql</category>
      <category>cdn</category>
    </item>
  </channel>
</rss>
