Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Sep 22, 2026·16 min read

Fragment Colocation, Optimistic UI & Incremental Delivery

Fragment colocation is not a Relay curiosity — it is the only mechanism that makes your UI's data contract explicit, refactor-safe, and immune to silent over-fetching without a server-side schema change.

Fragment Colocation, Optimistic UI & Incremental Delivery

Here is a refactor bug that happens to every team at scale. A designer adds a displayName field to the User type. The backend deploys it. A frontend engineer searches the codebase for UserProfile, finds the component, and adds displayName to the query in pages/profile.tsx. Done — except the exact same user data is also fetched in the sidebar, the search results, and the notification list. Three components still showing the missing field. No grep found them, no TypeScript error fired, no test failed. Just wrong data in production, discovered by a user.
The fix is fragment colocation. Not as a Relay convention or an Apollo optimization — as the only architectural pattern that makes a component's data contract explicit, findable, and refactor-safe without relying on grep.
A GraphQL client is not a data fetcher — it is a local replica of your server's data graph. Fragment colocation makes the replica boundary explicit at the component level: each component owns exactly the slice of the replica it renders, and the fragment is the typed, co-located proof of that ownership.

1. The Colocation Principle

The problem with fetching fields in a page-level query is that the connection between the query and the components that use its data is implicit. profile.tsx fetches user.name and user.avatarUrl — but which component renders avatarUrl? Is it UserAvatar? ProfileHeader? Both?
Fragment colocation makes this connection explicit: the component that renders a field owns the fragment that declares it.

1.1 Without Colocation

typescript
// ❌ pages/profile.tsx — page owns all field selections
const GET_PROFILE = gql`
  query GetProfilePage($id: ID!) {
    user(id: $id) {
      id
      name
      avatarUrl
      displayName    # ← Who renders this? Unknown without reading every child component
      bio
      orders { id total createdAt }
    }
  }
`

function ProfilePage({ userId }: { userId: string }) {
  const { data } = useQuery(GET_PROFILE, { variables: { id: userId } })
  return (
    <>
      <UserAvatar user={data?.user} />
      <UserBio user={data?.user} />
      <OrderList user={data?.user} />
    </>
  )
}
When UserAvatar needs a new field, the engineer must modify GET_PROFILE in profile.tsx — a file that UserAvatar doesn't own. When GET_PROFILE is changed, there's no automated way to know which components break.

1.2 With Colocation

typescript
// ✅ UserAvatar.tsx — component declares exactly what it needs
export const UserAvatarFragment = gql`
  fragment UserAvatarFragment on User {
    id
    avatarUrl
    displayName
  }
`

export function UserAvatar({ user }: { user: UserAvatarFragment }) {
  return (
    <div className="avatar">
      <img src={user.avatarUrl} alt={user.displayName} />
    </div>
  )
}
typescript
// ✅ pages/profile.tsx — page composes child fragments
import { UserAvatarFragment } from '../components/UserAvatar'
import { UserBioFragment } from '../components/UserBio'
import { OrderListFragment } from '../components/OrderList'

const GET_PROFILE = gql`
  query GetProfilePage($id: ID!) {
    user(id: $id) {
      ...UserAvatarFragment
      ...UserBioFragment
      ...OrderListFragment
    }
  }
  ${UserAvatarFragment}
  ${UserBioFragment}
  ${OrderListFragment}
`
Now when UserAvatar needs a new field, the engineer adds it to UserAvatarFragment in UserAvatar.tsx. The page query picks it up automatically on the next graphql-codegen run. The TypeScript type for UserAvatarFragment updates. The change is co-located with the component that owns it.
Page query assembling UserAvatarFragment, UserBioFragment, and OrderListFragment — fragment ownership boundaries annotated as the visually loudest element
Page query assembling UserAvatarFragment, UserBioFragment, and OrderListFragment — fragment ownership boundaries annotated as the visually loudest element

2. Fragment Composition at the Page Level

The page-level query is always exactly one network request. Fragment composition doesn't create multiple requests — it assembles all the fields needed by all child components into a single operation:
typescript
// One network request, all components satisfied
const GET_PROFILE = gql`
  query GetProfilePage($id: ID!) {
    user(id: $id) {
      ...UserAvatarFragment    # fetches avatarUrl, displayName
      ...UserBioFragment       # fetches bio, joinedAt
      ...OrderListFragment     # fetches orders { id total createdAt }
    }
  }
  ${UserAvatarFragment}
  ${UserBioFragment}
  ${OrderListFragment}
`
This eliminates both over-fetching (each fragment requests only its fields) and waterfall fetching (all fragments are batched into one request). The parent component doesn't need useEffect chains or cascading queries — it declares what it needs once and renders.
Pro Tip & Optimization
When using @graphql-codegen/cli v5 with the client preset, fragments are automatically inlined — you don't need ${FragmentName} template literal interpolation. Import the typed fragment document and spread it directly in the parent operation using the useFragment hook instead.

3. useFragment: Reading the Replica Without Props

Apollo Client 3.8+ introduced useFragment, which reads a fragment directly from the normalized cache without requiring a prop to be passed down:
typescript
import { useFragment } from '@apollo/client'
import { graphql } from '../gql'

const UserAvatarFragmentDoc = graphql(`
  fragment UserAvatarFragment on User {
    id
    avatarUrl
    displayName
  }
`)

export function UserAvatar({ userId }: { userId: string }) {
  // ✅ Reads User:${userId} directly from the cache replica
  const { data: user } = useFragment({
    fragment: UserAvatarFragmentDoc,
    from: { __typename: 'User', id: userId },
  })

  if (!user) return null
  return <img src={user.avatarUrl} alt={user.displayName} />
}
useFragment re-renders UserAvatar only when avatarUrl or displayName changes in the User:${userId} cache entry — not when other User fields change. This is a significant performance benefit in large component trees where a mutation updates a single user field.
Architectural Note
useFragment does not trigger a network request. It reads from the existing replica. The parent component's useQuery populates the cache; useFragment reads it. If User:${userId} is not in the cache, data is null.

4. Concurrent React Integration: Suspense and @defer

React 18's Concurrent features (Suspense, useTransition, startTransition) require a different data-fetching API from the old loading boolean pattern. Apollo Client 3.10+ provides stable useSuspenseQuery for this.

4.1 useSuspenseQuery — The Correct API for React 18

typescript
import { useSuspenseQuery } from '@apollo/client'
import { graphql } from '../gql'

const GET_PROFILE = graphql(`
  query GetProfilePage($id: ID!) {
    user(id: $id) {
      ...UserAvatarFragment
      ...UserBioFragment
    }
  }
`)

// ✅ Component suspends while data loads — no loading boolean
function ProfilePage({ userId }: { userId: string }) {
  const { data } = useSuspenseQuery(GET_PROFILE, {
    variables: { id: userId },
  })
  // data is guaranteed non-null here — component only renders after data arrives
  return <UserAvatar userId={data.user.id} />
}

// Parent wraps with Suspense boundary
function App() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <ProfilePage userId="1" />
    </Suspense>
  )
}
The loading boolean pattern (if (loading) return <Spinner />) is incompatible with React's Concurrent mode. Components that return early during render can't be interrupted and resumed by the scheduler. useSuspenseQuery integrates correctly with React 18's rendering model.

4.2 @defer — Render Critical Fields Immediately

The @defer directive lets a single query deliver fields in multiple chunks — critical fields arrive immediately, slow fields stream in afterward:
graphql
query GetProfilePage($id: ID!) {
  user(id: $id) {
    # ✅ Critical fields — delivered immediately
    id
    name
    avatarUrl

    # ⏳ Slow fields — deferred, streamed when ready
    ... @defer {
      orderHistory { id total createdAt }
      recommendations { id title }
    }
  }
}
typescript
function ProfilePage({ userId }: { userId: string }) {
  const { data } = useSuspenseQuery(GET_PROFILE, {
    variables: { id: userId },
  })

  return (
    <>
      {/* Renders immediately from the first chunk */}
      <UserAvatar user={data.user} />

      {/* Suspense boundary for deferred fields — renders when the second chunk arrives */}
      <Suspense fallback={<OrderSkeleton />}>
        <OrderHistory user={data.user} />
      </Suspense>
    </>
  )
}
@defer requires:
  • Apollo Router ≥ 1.17 on the server (handles multipart HTTP streaming)
  • Apollo Client 3.8+ on the client (handles the incremental delivery protocol)
  • Suspense boundaries around deferred field zones — without them, React doesn't know where to render the loading state
Crucial Requirement
@defer reduces time-to-interactive, not time-to-complete. The page becomes interactive after the first chunk. The deferred fields arrive later and fill in progressively. Use it when some fields are significantly slower than others and you don't want to block the entire page on the slowest resolver.

5. Optimistic UI

Optimistic UI makes mutations feel instant by writing the expected result to the cache before the server responds. The cache holds the optimistic value temporarily; the actual server response either confirms it (reconcile) or reverts it (rollback).

5.1 The Two-Write Model

typescript
import { useMutation } from '@apollo/client'
import { graphql } from '../gql'

const UPDATE_USER_NAME = graphql(`
  mutation UpdateUserName($id: ID!, $name: String!) {
    updateUser(id: $id, name: $name) {
      id
      name
    }
  }
`)

function UserNameEditor({ userId, currentName }: { userId: string; currentName: string }) {
  const [updateUserName] = useMutation(UPDATE_USER_NAME, {
    optimisticResponse: {
      updateUser: {
        __typename: 'User',
        id: userId,
        name: 'Pending...', // ← Write 1: immediate optimistic value
      },
    },
    // Write 2: server response automatically reconciles the cache entry
  })

  return (
    <button onClick={() => updateUserName({ variables: { id: userId, name: 'Alice' } })}>
      Rename
    </button>
  )
}
As soon as updateUserName is called:
  1. Apollo writes the optimisticResponse to User:${userId} in the cache
  2. All components reading User:${userId}.name re-render with the optimistic value
  3. The network request fires concurrently
  4. When the server responds, Apollo writes the actual result to the cache
  5. If the server response matches the optimistic value, nothing changes visually
useMutation with optimisticResponse — optimistic cache write fires immediately (cyan), server response arrives and either reconciles (green) or triggers rollback (red)
useMutation with optimisticResponse — optimistic cache write fires immediately (cyan), server response arrives and either reconciles (green) or triggers roll…

5.2 Failure Modes

typescript
const [updateUserName] = useMutation(UPDATE_USER_NAME, {
  optimisticResponse: {
    updateUser: {
      __typename: 'User',
      id: userId,
      name: newName,
    },
  },
  // ✅ Explicit rollback on failure
  onError(error, { cache }) {
    // Apollo does NOT automatically roll back with errorPolicy: 'all'
    cache.modify({
      id: cache.identify({ __typename: 'User', id: userId }),
      fields: {
        name: () => currentName, // Restore the pre-mutation value
      },
    })
  },
  errorPolicy: 'all', // Receive partial data + errors together
})
Performance / Safety Warning
The optimisticResponse shape must exactly match the mutation's return type — including __typename on every nested object. A shape mismatch causes Apollo to fail silently: the optimistic write succeeds, the server response can't be normalized, and the cache may remain in an incorrect optimistic state indefinitely.

Summary

ConceptRule
Fragment as contractA fragment is a typed data contract between a component and the graph — deleting a field from the fragment is a compiler-enforced, safe refactor; removing a prop is a runtime guess.
Single network requestFragment composition at the page level means the network request is always a single operation — no waterfall, no component-level useEffect fetch chains.
useFragmentuseFragment reads directly from the normalized cache replica — no prop required, and the component re-renders only when its specific fragment's fields change.
Optimistic response shapeOptimistic UI requires the optimisticResponse shape to exactly match the mutation return type — a shape mismatch causes Apollo to skip rollback on failure, leaving the cache in a corrupted optimistic state.
@defer purpose@defer reduces time-to-interactive, not time-to-complete — it is the correct tool when a critical subset of the page must be immediately interactive while slow fields continue resolving.

What's Next

In Part 5, we add a live dimension to the client replica: subscriptions. We'll look at why WebSocket authentication doesn't work the way HTTP auth does, why subscribeToMore is the only correct pattern for merging subscription events into the cache, and how the transport protocol choice (WebSocket vs. SSE) determines your server's horizontal scaling ceiling.
Research & Synthesis Note

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

#GraphQL#Apollo Client#Fragments#Optimistic UI#@defer#React#Suspense
Siddhant Deval

Written by Siddhant Deval

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