facebook/relay-performance
>- Performance best practices for Relay applications. Use when optimizing data fetching, reducing re-renders, configuring caching, or improving time to first meaningful paint. Covers query placement, @defer, pagination, fetch policies, garbage collection, fragment granularity, and server-side filtering. Companion to the relay-best-practices skill which covers correctness and architecture.
npx skills add https://github.com/facebook/relay --skill relay-performance
Performance-focused guidance for Relay applications. For correctness, naming,
and architectural patterns, see the relay-best-practices skill.
For detailed API documentation, read the relevant page from <llm-docs>/
(available in node_modules/relay-runtime/llm-docs/ after v20.1.1).
Each screen or route should have one (or very few) root queries. Relay
coalesces all fragment data needs into a single network request per query.
Multiple root queries on the same screen defeat this optimization — the browser
makes multiple parallel requests that each carry redundant overhead (HTTP
headers, connection setup, response parsing).
GOOD: BAD:
Route → 1 query Route → 3 queries
├─ Header (fragment) ├─ Header (query #1)
├─ Content (fragment) ├─ Content (query #2)
└─ Sidebar (fragment) └─ Sidebar (query #3)
Fetch the initial query before calling createRoot().render(). This
overlaps the network request with React's initialization, minimizing time to
first meaningful paint.
// Start fetch immediately — before React even initializes
const queryRef = loadQuery(environment, AppQuery, initialVariables);
// Then render — data may already be available
const root = createRoot(document.getElementById('root'));
root.render(
<RelayEnvironmentProvider environment={environment}>
<Suspense fallback={<AppSkeleton />}>
<App queryRef={queryRef} />
</Suspense>
</RelayEnvironmentProvider>
);
@defer for Non-Critical ContentDefer secondary or below-the-fold content so primary UI renders faster.
Relay streams deferred data progressively via Suspense — the initial response
arrives smaller and the critical path renders sooner.
query ProfileScreenQuery($id: ID!) {
user(id: $id) {
...ProfileHeader_user
...ProfileDetails_user @defer
...ProfileComments_user @defer
}
}
function ProfileScreen({ queryRef }) {
const data = usePreloadedQuery(ProfileScreenQuery, queryRef);
return (
<ScrollView>
<ProfileHeader user={data.user} />
<Suspense fallback={<DetailsSkeleton />}>
<ProfileDetails user={data.user} />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<ProfileComments user={data.user} />
</Suspense>
</ScrollView>
);
}
Good candidates for @defer:
store-or-network (the default) is correct for most cases — it reuses cached
data and only hits the network for missing or stale data.
| Policy | When to use |
|--------|-------------|
| store-or-network | Default. Best balance of speed and freshness. |
| store-and-network | Show cached data immediately, update in background. |
| network-only | Freshness is critical (e.g., after a mutation with wide side effects). |
| store-only | Offline-first or reading data already guaranteed to be in the store. |
Reserve network-only for rare cases. Overusing it turns Relay into a
no-cache client and eliminates the benefit of the normalized store.
Set gcReleaseBufferSize on the Relay Store to retain recently-used queries
after their components unmount. The default is 10. This makes navigating back
to a previously visited screen instant (data is still in the store) instead of
triggering a new network request.
const store = new Store(new RecordSource(), {
gcReleaseBufferSize: 20,
});
For apps with many screens or heavy navigation, increase the buffer. For
memory-constrained environments (mobile), keep it conservative.
Use GraphQL field arguments to filter and sort data on the server rather than
fetching everything and processing in JavaScript.
// BAD: fetch all tasks, filter on client
const data = useFragment(graphql`
fragment TaskList_user on User {
tasks { id, title, status }
}
`, user);
const active = data.tasks.filter(t => t.status === 'ACTIVE');
// GOOD: filter on server via field argument
const data = useFragment(graphql`
fragment TaskList_user on User {
tasks(status: ACTIVE) { id, title }
}
`, user);
Server-side filtering reduces payload size, avoids unnecessary network
transfer, and reduces memory usage on the client.
Always paginate list fields using @connection + usePaginationFragment.
Fetching an entire collection at once risks transferring megabytes of data,
stalling the UI during normalization, and exhausting device memory.
Start with a page size appropriate for the viewport (e.g., 10–20 items) and
load more on scroll.
# BAD: fetches every item — unbounded
fragment NotificationList_user on User {
notifications {
id
message
}
}
# GOOD: paginated with a bounded first page
fragment NotificationList_user on User
@argumentDefinitions(
count: { type: "Int", defaultValue: 10 }
cursor: { type: "String" }
)
@refetchable(queryName: "NotificationListPaginationQuery") {
notifications(first: $count, after: $cursor)
@connection(key: "NotificationList_notifications") {
edges {
node {
id
message
}
}
}
}
Split large fragments into smaller, component-scoped fragments so Relay can
re-render only the components whose data actually changed. A single
monolithic fragment shared by many components causes all of them to re-render
when any field in the fragment changes.
// BAD: one large fragment, all children re-render on any field change
function PostCard({ post }) {
const data = useFragment(graphql`
fragment PostCard_post on Post {
title
body
author { name, profilePicture { uri } }
likeCount
commentCount
}
`, post);
return (
<>
<PostHeader title={data.title} author={data.author} />
<PostBody body={data.body} />
<PostFooter likes={data.likeCount} comments={data.commentCount} />
</>
);
}
// GOOD: each child owns its fragment, re-renders independently
function PostHeader({ post }: { post: PostHeader_post$key }) {
const data = useFragment(graphql`
fragment PostHeader_post on Post {
title
author { name }
}
`, post);
// Only re-renders when title or author.name changes
}
function PostFooter({ post }: { post: PostFooter_post$key }) {
const data = useFragment(graphql`
fragment PostFooter_post on Post {
likeCount
commentCount
}
`, post);
// Only re-renders when like/comment counts change
}
Use a single usePaginationFragment per component. Multiple connections in
one component make pagination state harder to reason about — cursor tracking,
loading states, and hasNext flags become tangled. Split each connection into
its own component instead.
After a mutation, let Relay's normalized store auto-update components by
spreading relevant fragments in the mutation response. Do not call refetch()
or fetchQuery() when the store update is sufficient.
# GOOD: updated data comes back with the mutation response
mutation UpdateUserMutation($input: UpdateUserInput!) {
updateUser(input: $input) {
user {
...UserProfile_user
...UserAvatar_user
}
}
}
# BAD: requires a separate round-trip after mutation
mutation UpdateUserMutation($input: UpdateUserInput!) {
updateUser(input: $input) {
user { id }
}
}
Use fetchKey sparingly — changing it forces a full network round trip.
Reserve refetchQueries / manual refetch for cases where the mutation's side
effects are too broad to capture in the response payload.
Each fragment should request only the fields the component actually renders.
Do not add fields "just in case" — unused fields increase payload size and slow
down parsing and normalization. Relay's unused-fields lint rule catches this.
If a child component needs more data, add a fragment to the child and
spread it in the parent — do not widen the parent's fragment.
Take facebook/relay-performance from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.