GraphQL at Scale: Client-Side Query Patterns
As GraphQL adoption grows, so do the performance problems that come with it. Queries become larger and slower, and new feature rollouts get harder to manage. Engineers on Shopify's Orders & Fulfillments mobile and web teams spent two years scaling their Order screen, with a target of sub-one-second page loads on reliable networks. Along the way, they developed a set of client-side querying strategies worth sharing.
Consider a basic query powering a product list screen: it fetches the first 100 products with name, price, and image. That works fine with a small catalog, but it raises three questions immediately: how do you handle pagination, how do you ship new fields safely, and how do you keep the query fast as it grows?
Pagination and Performance Tripwires
The first step is acknowledging that your endpoint is paginated on the backend and implementing pagination in the client. The tricky part is choosing the right page size. It will likely differ per platform—a mobile client renders fewer products at once, so a smaller page size makes sense there. Page size has UX and performance implications, so it needs deliberate thought rather than a one-size-fits-all default.
To keep an eye on performance as pagination is introduced, set up tripwires: measurable loading-time scores that flag when things start degrading. Adding hasNextPage to the query tells the client whether more products exist, enabling infinite scroll or a "load more" button without over-fetching.
Feature Flagging New Fields
With pagination handled, the next bottleneck is feature velocity. Multiple teams shipping fields to the same query creates merge conflicts and risky rollouts. Shopify's approach uses @include and @skip directives to gate new fields behind feature flags:
In practice, a description field is wrapped in an @include(if: $featureFlag). When the flag is false, the field comes back as null, so the client has to handle null unwrapping. The constraint here is that gated fields must keep their names and positions stable—renaming or deleting them breaks the query. For situations where that constraint is too rigid, dynamically building the query string at runtime based on the flag value is an alternative, though it trades in type safety and readability.
Other rollout strategies exist—duplicating entire queries per feature branch, for example—but they introduce redundancy and complexity. The directive approach is cleanest for flags you control locally, but what about remote configuration?
Chained Queries and Their Costs
Sometimes one query depends on another. Chaining becomes necessary when:
- A feature flag comes from a remote query, enabling server-side rollout control across many mobile app versions in production.
- A field in the query is powered by a remote parameter, a scenario driven by backend constraints.
- A UX that doesn't support pagination forces loading every page up front on screen load.
Chaining means executing the remote flag query first, waiting for the response, then firing the product list query with the flag value injected through an alias feeding an @include directive. The cost is immediate: two sequential round-trips per page load, which meaningfully slows perceived performance. The guidance here is blunt: only chain when you have no other option. If remote flags are required at screen load, consider moving the flag query up to an app-wide level where it can be fetched once, rather than per screen.
Parallel Queries for Independent Data
The product list screen eventually accumulates unrelated data: search filters, user permissions, banners. Bundling these into the paginated ProductsList query means re-fetching them on every page turn, wasting bandwidth and slowing responses. Splitting these into parallel queries offers several benefits:
- Faster screen loads: independent fragments are resolved concurrently by the server rather than queued behind the full query.
- Cleaner collaboration: one query per endpoint reduces merge conflicts and makes feature contributions simpler as teams grow.
- Room for partial rendering: as individual queries complete, the client can render incrementally, creating a faster perceived load.
- No redundant fetching: the paginated endpoint lives in its own query, so it can be re-run for the next page without repeating the request for filters or banners.
This pattern has an obvious corollary: when one of the parallel queries gets too big, split it again using the same principles. "Too big" is a judgement call, and tripwires are the guide. Once the query load starts affecting loading-time targets, it's time to partition. The result is organic growth where each query scales on its own timeline without compound performance hits across the whole screen.
A caveat: parallel queries push load from the client onto the server. That shift needs monitoring. Work with site reliability engineers and backend developers to track server-side performance and ensure the new query pattern doesn't overwhelm the infrastructure. On the client, plugging partial responses into screen state requires some refactoring, but it's also an opportunity to implement partial rendering as part of the same change.
These patterns were developed for Shopify's Orders screen, but they apply to any GraphQL client facing growing query complexity. Paginate deliberately, gate new fields with directives or remote flags, avoid chaining unless necessary, and split independent concerns into parallel queries. None of this is a one-time fix; it's an ongoing discipline of measuring, splitting, and cleaning up as features land.



