@defer and @stream: Incremental Delivery Comes to GraphQL

For a decade, GraphQL responses came back as one lump. You asked for a page, you waited for every field to resolve, then the server wrapped it up and sent it over the wire in one envelope. Fast fields waited for slow fields. The whole response moved at the speed of its slowest resolver. Then @defer and @stream arrived to fix exactly that. They've been maturing as a proposal for several years - deliberately, because getting incremental delivery right is genuinely hard - and they're now real enough to use with modern clients and servers. The open question is when they pay off, and both camps have a case.
The Old Problem
A page loads. The client fires one GraphQL query for everything it needs:
query MovieDetail($id: ID!) {
movie(id: $id) {
id
title # 2ms from cache
poster # 2ms from cache
cast { # 800ms from Cast Service
name
role
}
reviews { # 2000ms from Review Aggregator, expensive AI sentiment score
text
sentiment
author { name }
}
}
}
Total latency: ~2 seconds. But 90% of what the user immediately sees (title, poster, headline info) was ready in 2ms. They're staring at a loading spinner for two seconds because the server is waiting for the reviews aggregator to finish summing up sentiment scores.
The client could break this into three queries. One for the essentials, one for the cast, one for the reviews. Suddenly you're orchestrating requests on the frontend, losing the "one query" simplicity that was half the point of using GraphQL.
@defer and @stream let you keep one query while getting the incremental rendering benefit.
How @defer Works
The @defer directive says: "this field fragment is lower priority. Send me the rest first, then catch up with this one when it's ready."
query MovieDetail($id: ID!) {
movie(id: $id) {
id
title
poster
... on Movie @defer(label: "slow-stuff") {
cast { name role }
reviews {
text
sentiment
author { name }
}
}
}
}
The server now streams two responses:
First response (2ms):
{
"data": {
"movie": {
"id": "42",
"title": "Inception",
"poster": "https://..."
}
},
"pending": [
{ "id": "0", "path": ["movie"], "label": "slow-stuff" }
],
"hasNext": true
}
Second response (2000ms):
{
"incremental": [{
"id": "0",
"data": {
"cast": [...],
"reviews": [...]
}
}],
"completed": [{ "id": "0" }],
"hasNext": false
}
Note the shape. The initial payload announces what is still coming in a pending
array, each entry carrying an id; later payloads deliver data against that id
and confirm it in completed. Correlating on an id instead of re-sending the
path each time is what makes @stream and nested @defer tractable.
Earlier drafts did not use pending or completed arrays: each incremental payload
carried its own path and label, and clients matched on the path. GraphQL.js
v17 moved to the format above, and keeps the previous one available through
legacyExecuteIncrementally() for hosts that need it.
The practical consequence: the payload shape you get depends on the protocol you
negotiated in the Accept header, so a client written against one shape and a
server emitting the other fail in confusing ways. Check which one your server
produces before writing the client.
The client renders the title and poster at 2ms. The cast and reviews fill in when they're ready. Nothing else changes in your client code except that it processes multiple response payloads instead of one.
How @stream Works
@stream is for list fields. Instead of waiting for all items, you get items as they're produced.
query Timeline {
timeline @stream(initialCount: 3) {
id
text
author { name }
}
}
First response: the first 3 items, returned as soon as they're ready.
{
"data": {
"timeline": [
{ "id": "1", "text": "...", "author": {...} },
{ "id": "2", "text": "...", "author": {...} },
{ "id": "3", "text": "...", "author": {...} }
]
},
"pending": [{ "id": "0", "path": ["timeline"] }],
"hasNext": true
}
Subsequent responses: each additional item as it arrives.
{
"incremental": [{
"id": "0",
"items": [
{ "id": "4", "text": "...", "author": {...} }
]
}],
"hasNext": true
}
This is useful when your list has a slow tail. The first few items load fast, but item 4 requires a call to a flaky partner API. Instead of waiting for the whole list, the client renders what it has and tops up as the rest trickles in.
The Transport Layer: multipart/mixed
A GraphQL request returning multiple payloads can't use a standard single-response HTTP exchange. Instead of inventing something new, the spec reached for multipart/mixed, a proven content type that has carried multi-part HTTP payloads for decades. Reusing a battle-tested standard instead of inventing a bespoke one is a pragmatic, interoperable choice.
POST /graphql HTTP/1.1
Content-Type: application/json
Accept: multipart/mixed; incrementalSpec=v0.2, application/json
The server responds:
HTTP/1.1 200 OK
Content-Type: multipart/mixed; boundary="-"
---
Content-Type: application/json; charset=utf-8
{"data":{"movie":{"id":"42","title":"Inception","poster":"..."}}, "hasNext":true}
---
Content-Type: application/json; charset=utf-8
{"incremental":[{"path":["movie"],"data":{"cast":[...]}}],"hasNext":false}
-----
Each chunk is a separate JSON payload, separated by the boundary string. The connection stays open until hasNext is false.
Not every HTTP client handles multipart/mixed streaming. fetch() in modern browsers does, via response.body.getReader(). Most backend HTTP clients don't without plugin support. If your client is an old iOS app, a CLI tool, or a server-side integration, @defer and @stream silently fall back to "wait for everything" behavior.
Alternative transports exist. Apollo's docs mention Server-Sent Events and WebSocket. In practice, multipart/mixed is the one the spec blessed, and it's what Apollo Client, Relay, and urql support natively.
Server-Side: graphql-java and Spring
graphql-java's support for incremental delivery landed in 22.x. Spring GraphQL exposes it, but it's still experimental territory in 2026. The mental model: your resolver returns a CompletableFuture for deferred fields, and the framework handles the chunked response.
@Controller
public class MovieController {
private final MovieService movieService;
private final ReviewService reviewService;
public MovieController(MovieService movieService, ReviewService reviewService) {
this.movieService = movieService;
this.reviewService = reviewService;
}
@QueryMapping
public Movie movie(@Argument Long id) {
return movieService.findById(id);
}
// Deferred: returned async, sent in a second chunk
@SchemaMapping
public CompletableFuture<List<Review>> reviews(Movie movie) {
return reviewService.loadReviewsAsync(movie.id());
}
}
The framework decides whether to defer based on the client's query. If the client uses @defer on the reviews field, the response streams in chunks. If they don't, the response is single-envelope as always. Your code doesn't branch based on client behavior.
The server must still support the non-deferred path. If half your clients don't understand multipart/mixed, they send a regular query without @defer, and you return a single response. @defer is a per-request opt-in, never a server-side default. This means your total latency for non-supporting clients is unchanged.
Client-Side: Apollo and Relay
Apollo Client (v3.7+) supports @defer transparently. Your component hooks work the same way; the UI renders deferred fields as they arrive.
const MOVIE_QUERY = gql`
query MovieDetail($id: ID!) {
movie(id: $id) {
id
title
poster
... on Movie @defer {
cast { name role }
reviews { text author { name } }
}
}
}
`;
function MovieDetail({ id }) {
const { data, loading } = useQuery(MOVIE_QUERY, { variables: { id } });
if (loading && !data) return <Spinner />;
return (
<div>
<h1>{data.movie.title}</h1>
<img src={data.movie.poster} />
{data.movie.cast
? <CastList cast={data.movie.cast} />
: <Skeleton />}
{data.movie.reviews
? <Reviews reviews={data.movie.reviews} />
: <Skeleton />}
</div>
);
}
The first render has title and poster. Subsequent renders light up cast and reviews independently as chunks arrive. No manual state machine, no coordinating multiple queries. Just fragments with @defer on them.
Relay has similar support, tightly integrated with React Suspense. The ergonomics are good. The ceremony is minimal.
The "It's Overkill" Argument
WunderGraph has a post arguing that @defer and @stream are overengineered solutions to a problem most teams don't have. The argument boils down to:
-
Most queries don't need it. For a typical page load, the extra transport complexity gives you 100-200ms of perceived latency improvement that a loading skeleton already handles.
-
HTTP/2 already does multiplexing. You can fire multiple GraphQL queries in parallel and get most of the same benefit with standard request/response semantics.
-
Client support is patchy. A lot of HTTP clients, caching layers, proxies, and API gateways don't understand
multipart/mixed. You'll find out at the worst possible time. -
It complicates caching. Your CDN sees a long-lived connection with multipart content instead of a cacheable response. Full-response caching is out the window.
-
The spec took its time. The working group worked through the details carefully over several years, and teams that wanted incremental delivery sooner built interim workarounds that still hold up fine.
All of these points are legitimate. @defer and @stream are not a free win. They're a tradeoff, and the tradeoff only pays off in specific scenarios.
When To Actually Use @defer
-
Single-query-for-a-whole-page architectures. If your app uses one big query per page and you don't want to break it up,
@defergets you incremental rendering without restructuring. -
Above-the-fold vs below-the-fold. Hero content is fast, supplementary content is slow. Defer the below-the-fold.
-
Legally-required-to-show-first data. Some regulated UIs (finance, health) must render specific compliance info before other data.
@deferlets you prioritize explicitly. -
Mobile on flaky networks. Showing any data quickly is much better than blocking on a slow payload.
-
Simple CRUD screens. If your page query takes 80ms total, deferring the slow 30ms field saves you 30ms you won't notice.
-
Server-rendered responses. If you're SSR'ing the page, you need the full payload before flushing HTML anyway.
-
Cache-heavy architectures. If your frontend relies on CDN caching the response, multipart breaks that.
-
APIs exposed to third parties. Your public API consumers use HTTP clients that may not support multipart streaming. Don't force them to.
When To Actually Use @stream
@stream is narrower. It's useful when:
- Lists are long
- Tail items are slow to produce
- You want the user to see the top of the list before the bottom is ready
- Infinite-scroll style UIs
It's misused when:
- Lists are short (10 items? just return them)
- You want traditional pagination (use a connection, don't stream)
- The whole list comes from a single DB query (streaming doesn't save anything here)
What This Means Right Now
If you're using modern Apollo Client or Relay against a graphql-java 22+ server, you can experiment with @defer today. Check the protocol version on both ends first: GraphQL.js v17 shipped in June 2026 with the pending/completed response format described above, and anything written against the older shape needs updating. Note that graphql-java 22 added @defer only - @stream came in a later release - so check your server version against what you actually want to use. Find one or two queries where you're blocking the whole page on one slow field. Add a @defer fragment. Measure the change.
The most production-mature @defer deployment story today is Apollo Router. It shipped @defer support at v1.0 (September 2022) with entity-based @defer initially in preview, and later brought @defer to GA (support requires Router v1.8.0+). It can also synthesize entity-based @defer even when individual subgraphs don't natively support it.
If the numbers are good, great. Adopt it where it helps. If the numbers are marginal, you've learned something: your bottleneck wasn't the thing you thought it was.
What @defer and @stream are absolutely not: a replacement for good query design. If you're using @defer to mask the fact that your schema has a field that takes 2 seconds to resolve, fix the resolver. Incremental delivery is not a performance strategy. It's a perceived-performance strategy. Real performance fixes still require boring work like:
- Adding DataLoaders for N+1
- Caching expensive computations
- Bounding list returns
- Indexing your database
@defer and @stream are nice polish on top of a well-built API. They are not glue for a broken one.
The Verdict
With incremental delivery, GraphQL brings the streaming benefits of HTTP chunked encoding and SSE into a schema-aware, single-query model - the kind of structured approach it's known for. Be clear-eyed about the maturity, though: incremental delivery is still an RFC, experimental and not yet merged into the main GraphQL spec. The working-group document carries a plain "Working Draft" header with no stage attached, and Apollo's documentation describes it as a Stage 1 proposal, so be wary of any source that states its stage confidently. The very fact that the wire format is versioned (incrementalSpec=v0.2, used by graphql-js v17 alphas and Apollo Server 5) is a sign it is not final and can still change. Treat production use as early-adopter territory, not a settled standard. Client support is improving, server support is maturing, and the DX is fine, but the blast radius of adopting it is larger than a typical directive: transport headers, client capabilities, caching behavior, and proxy compatibility all shift.
Use it where the payoff is real and the environment supports it. Skip it where the complexity outweighs the gain. This is a tool for your toolbox, not a default to turn on everywhere.
This post was written incrementally. The first draft was title, hook, and conclusion. The middle chunks came in one at a time. Each chunk had hasNext: true until the author remembered coffee existed.