Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Oct 13, 2026·14 min read

Frontend Performance: Bundle Cost, APQ & Query Discipline

GraphQL's expressive query model is also its biggest frontend performance liability — the decision to have a local replica at all starts with bundle cost, and every subsequent optimization from APQ to Persisted Operations is about protecting the replica's query surface on the network.

Frontend Performance: Bundle Cost, APQ & Query Discipline

The first GraphQL performance decision happens before the first query is written. It is the npm install @apollo/client command. That command adds 47KB min+gzip to the client bundle — before any queries, before any components, before any cache configuration. The question that should have been asked before running it: do the normalized cache, the fragment colocation, and the optimistic UI together justify 34KB more than TanStack Query?
For many teams, the answer is no. For teams where it is yes, the second question is: are you getting full value from those 47KB? Most are not. They have Apollo Client installed, one useQuery per component, no field policies, no APQ, and no @cacheControl headers — 47KB of infrastructure generating the same load pattern as plain fetch.
A GraphQL client is not a data fetcher — it is a local replica of your server's data graph. Performance optimization starts with the decision to have a local replica at all, and every subsequent optimization (APQ, @cacheControl, Persisted Operations) protects that replica's query surface on the network.

1. Bundle Cost Is the First Performance Decision

1.1 The Real Bundle Comparison

ClientSize (min+gzip)Cache ModelBest For
@apollo/client~47KBNormalized, entity-levelGraph APIs, optimistic UI, fragment colocation
@tanstack/react-query~13KBResponse-level (per query key)REST APIs, simple GraphQL, flat data
urql~14KBNormalized (optional)Lightweight GraphQL, SSR-first setups
swr~4KBResponse-levelSimple data fetching, no GraphQL features
The 34KB difference between Apollo Client and TanStack Query is the cost of the replica model. Justify it with at least two of these:
  • You use useFragment or fragment colocation for component-level data ownership
  • You use optimistic UI with optimisticResponse and cache.modify
  • You consume a federated supergraph where entity-level cache consistency matters
  • You need @defer incremental delivery (requires Apollo Client 3.8+)
typescript
// If you are doing this — Apollo Client is not justified
const { data } = useQuery(GET_USERS)
return <ul>{data?.users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
// Use TanStack Query instead — same result, 34KB smaller bundle

1.2 Import Path Optimization

If Apollo Client is the correct choice, tree-shaking eliminates unused exports — but only if you import from the correct paths:
typescript
// ❌ Full barrel import — prevents tree-shaking
import { ApolloClient, useQuery, gql } from '@apollo/client'

// ✅ Subpath imports — tree-shakeable
import { ApolloClient } from '@apollo/client/core'
import { useQuery } from '@apollo/client/react'
Pro Tip & Optimization
Run npx bundlephobia @apollo/client to see the exact bundle cost before installing. Run npx @next/bundle-analyzer or npx vite-bundle-visualizer after installing to confirm tree-shaking is effective.

2. Automatic Persisted Queries (APQ)

GraphQL queries are large strings — a production operation with fragments can easily be 2–5KB. APQ replaces those strings with a SHA-256 hash on repeat requests:

2.1 The APQ Flow

Request 1 (cold start — query not yet registered on server):
  Client → Server: GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}
  Server → Client: { errors: [{ message: "PersistedQueryNotFound" }] }
  Client → Server: POST /graphql { query: "query GetUser...", extensions: {"sha256Hash":"abc123"} }
  Server: registers "abc123" → "query GetUser..." in memory
  Server → Client: { data: { user: {...} } }

Request 2+ (hash registered — steady state):
  Client → Server: GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}
  Server → Client: { data: { user: {...} } }  (from server cache or CDN)
The GET conversion is the critical benefit. CDNs (Cloudflare, Fastly, Varnish) cache GET requests by URL. A POST request is never cached by CDN — the body isn't part of the cache key. Converting to GET makes the query cacheable.
typescript
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries'
import { sha256 } from 'crypto-hash'

const persistedQueriesLink = createPersistedQueryLink({
  sha256,
  useGETForHashedQueries: true,  // ← Converts APQ hits to GET requests for CDN cacheability
})

const client = new ApolloClient({
  link: from([
    authLink,
    retryLink,
    errorLink,
    persistedQueriesLink, // ← Before HttpLink, after auth
    httpLink,
  ]),
  cache: new InMemoryCache(),
})
APQ two-request lifecycle — cold start (2 round trips, server registration) vs steady state (GET request → CDN hit, zero server compute)
APQ two-request lifecycle — cold start (2 round trips, server registration) vs steady state (GET request → CDN hit, zero server compute)

3. Making APQ Meaningful: @cacheControl + CDN Headers

APQ alone converts queries to GET requests. But a GET request without a Cache-Control header is cached for max-age=0 — which means the CDN caches it but immediately considers it stale. Every request still hits the origin.
The @cacheControl directive on schema fields sets the TTL:
graphql
# schema.graphql (server-side)
type Query {
  product(id: ID!): Product @cacheControl(maxAge: 300)        # Cache 5 minutes
  userProfile(id: ID!): User @cacheControl(maxAge: 0)          # Never cache (user-specific)
  publicAnnouncements: [Announcement!]! @cacheControl(maxAge: 3600) # Cache 1 hour
}
Apollo Router propagates the most restrictive maxAge from the operation's field set to the HTTP Cache-Control: max-age=N response header. The CDN reads this header and caches accordingly.
The complete setup:
Client request (GET with hash) → CDN
  → CDN MISS: forwards to Apollo Router
    → Router executes query, field with @cacheControl(maxAge: 300) is lowest maxAge
    → Router sends: Cache-Control: max-age=300, public
  → CDN stores response for 300 seconds
  → Next client with same hash within 300s → CDN HIT, zero server compute
Crucial Requirement
@cacheControl only applies to public, non-personalized data. User-specific data (profile, cart, orders) must use @cacheControl(maxAge: 0) or scope: PRIVATE. Caching personalized data publicly is a data privacy bug.

4. Persisted Operations: APQ Without the Cold-Start Penalty

APQ has one limitation: the first request for any operation pays a two-round-trip cold-start penalty while the server registers the hash. In high-traffic environments, many users may hit this cold start simultaneously after a deployment.
Persisted Operations eliminates the cold start by registering all operations at build time:
bash
# Build step: generate the operation manifest
npx graphql-codegen --config codegen.ts

# Deploy manifest to the router before deploying the frontend
rover persisted-queries publish \
  --graph-id $GRAPH_ID \
  --manifest ./src/gql/persisted-query-manifest.json
typescript
// Apollo Client sends only the operation ID — no query string, no hash negotiation
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries'

const persistedQueriesLink = createPersistedQueryLink({
  generateHash: (document) => {
    // Returns the pre-computed ID from the manifest
    return manifestLookup.get(document) ?? sha256(print(document))
  },
})
The Apollo Router, with the manifest loaded, rejects any operation whose ID is not in the manifest:
json
// Response to an unknown operation
{
  "errors": [
    {
      "message": "PersistedQueryNotFound",
      "extensions": { "code": "PERSISTED_QUERY_NOT_FOUND" }
    }
  ]
}
This rejection is both a performance win (no expensive query parsing for unknown operations) and a security win (DoS attacks via complex queries are impossible — the router rejects anything not in the manifest before parsing).
Optimization comparison — no optimization vs APQ only vs APQ + @cacheControl vs Persisted Operations — across CDN hit rate, cold-start cost, server compute, and implementation effort
Optimization comparison — no optimization vs APQ only vs APQ + @cacheControl vs Persisted Operations — across CDN hit rate, cold-start cost, server compute,…

5. Query Discipline

5.1 watchFragment — Fine-Grained Reactive Reads

Apollo Client 3.10+ introduced watchFragment, a reactive cache subscription that's lighter than useQuery for read-only display components:
typescript
import { useWatchFragment } from '@apollo/client'
import { graphql } from '../gql'

const UserBadgeFragment = graphql(`
  fragment UserBadgeFragment on User {
    id
    name
    badgeCount
  }
`)

function UserBadge({ userId }: { userId: string }) {
  // ✅ Direct cache subscription — no network request, no useQuery overhead
  const { data } = useWatchFragment({
    fragment: UserBadgeFragment,
    from: { __typename: 'User', id: userId },
  })

  return <span>{data?.name} ({data?.badgeCount})</span>
}
watchFragment re-renders only when the specified fragment's fields change in the cache. For high-frequency updates (live counters, presence indicators), this avoids the useQuery re-render overhead for fields not in the fragment.

5.2 @skip and @include — Conditional Fields Without Extra Requests

typescript
const GET_USER = graphql(`
  query GetUser($id: ID!, $includeOrders: Boolean!) {
    user(id: $id) {
      id
      name
      orders @include(if: $includeOrders) {
        id
        total
      }
    }
  }
`)

// Dashboard: fetch with orders
useQuery(GET_USER, { variables: { id, includeOrders: true } })

// Sidebar: fetch without orders (same query, different variable)
useQuery(GET_USER, { variables: { id, includeOrders: false } })
Architectural Note
@skip and @include are evaluated at the client side before the query is sent — the server only receives the fields that were not skipped or excluded. This reduces response size and resolver execution cost, not just client-side rendering.
The BatchHttpLink performance advice was introduced in Part 3, but it's worth restating in the context of performance optimization:
typescript
// Check the protocol before choosing the link
const link = window.location.protocol === 'https:'
  ? new HttpLink({ uri: '/graphql' })          // HTTP/2 — native multiplexing
  : new BatchHttpLink({ uri: '/graphql' })     // HTTP/1.1 — batching helps
HTTP/2 multiplexes all requests over a single TCP connection concurrently. A batchInterval: 20ms delay in BatchHttpLink slows down the first request in the batch by 20ms with no connection benefit. On HTTP/2, individual HttpLink requests always outperform BatchHttpLink.

Summary

ConceptRule
Bundle costBundle cost is the first performance decision in any GraphQL client architecture — choosing Apollo over TanStack Query is a 34KB investment that must be justified by the replica model's benefits.
APQ and cachingAPQ alone does not create meaningful CDN caching — @cacheControl + Cache-Control headers set the cache TTL; without them, APQ produces CDN route hits with max-age=0.
Persisted OperationsPersisted Operations is APQ with the cold-start penalty eliminated: the operation manifest is compiled at build time, and the router rejects any query not in it — both a performance and security gain.
watchFragmentwatchFragment (Apollo Client 3.10+) is the correct API for components that need to reactively read a single cache slice — avoids the full useQuery re-render cycle for read-only display components.
BatchHttpLinkDisable BatchHttpLink in any HTTP/2 environment — multiplexing handles concurrent requests natively; batching adds an artificial delay that degrades time-to-first-byte.

What's Next

In Part 8, we look at the federated supergraph from the client's perspective — the @key entity fields that entity stitching requires, the partial data patterns when a subgraph fails, how to read a query plan in Apollo Studio, and how @defer works across subgraph boundaries.
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#GraphQL#Performance#APQ#Persisted Queries#Apollo Client#CDN#Bundle Size
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.