Repository: FormidableLabs/urql
Pull request #1854 (2021-08-11 22:35:18)

(experimental) - Add full support for multipart/mixed defer and stream directive responses
Add **experimental** support for `@defer` and `@stream` responses for GraphQL. This implements the ["GraphQL Defer and Stream Directives"](https://github.com/graphql/graphql-spec/blob/4fd39e0/rfcs/DeferStream.md) and ["Incremental Delivery over HTTP"](https://github.com/graphql/graphql-over-http/blob/290b0e2/rfcs/IncrementalDelivery.md) specifications. If a GraphQL API supports `multipart/mixed` responses for deferred and streamed delivery of GraphQL results, `@urql/core` (and all its derived fetch implementations) will attempt to stream results. This is _only supported_ on browsers [supporting streamed fetch responses](https://developer.mozilla.org/en-US/docs/Web/API/Response/body), which excludes IE11.

The implementation of streamed multipart responses is derived from [`meros` by `@maraisr`](https://github.com/maraisr/meros), and is subject to change if the RFCs end up changing.

For more information, read: https://graphql.org/blog/2020-12-08-improving-latency-with-defer-and-stream-directives

On fresh builds with a quick gzip check across all minified files (`$ cat packages/core/dist/*.min.mjs | gzip-size`) we see an increase from `5.32kB` minzipped to `6.02kB` minzipped, meaning a delta of approximately `700B`.
On Graphcache (`$ cat exchanges/graphcache/dist/*.min.mjs | gzip-size`) we see a change from `9.38kB` to `9.55kB`, a delta of `170B`.
Therefore we can estimate that the total impact of this feature is limited to `<1kB` minzipped.

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 2b1ed8caca1e7e4b618177074d3c8e9e7261a675

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/feat/incremental-multipart-responses?filename=.changeset/metal-paws-appear.md&value=---%0A%22%40urql%2Fcore%22%3A%20patch%0A---%0A%0AWIP%3A%20Add%20initial%20(isomorphic)%20incremental%20multipart%20support%0A)


kitten: We've released initial prerelease versions:

| Package | Version |
| -------- | -------- |
| `@urql/core@defer` | `2.3.0-defer.0` |
| `@urql/exchange-graphcache@defer` | `2.3.0-defer.0` |
| `@urql/exchange-execute@defer` | `2.3.0-defer.0` |

This will automatically enable this PR's patch. It may be necessary to create a resolution in Yarn, e.g. `"resolutions": { "@urql/core": "2.3.0-defer.0" }`, for the core package to remain deduplicated and on the new version.

Patch:
diff --git a/.changeset/tall-books-develop.md b/.changeset/tall-books-develop.md
new file mode 100644
index 00000000..c81fb0d5
--- /dev/null
+++ b/.changeset/tall-books-develop.md
@@ -0,0 +1,7 @@
+---
+'@urql/exchange-graphcache': minor
+'@urql/core': minor
+---
+
+Add **experimental** support for `@defer` and `@stream` responses for GraphQL. This implements the ["GraphQL Defer and Stream Directives"](https://github.com/graphql/graphql-spec/blob/4fd39e0/rfcs/DeferStream.md) and ["Incremental Delivery over HTTP"](https://github.com/graphql/graphql-over-http/blob/290b0e2/rfcs/IncrementalDelivery.md) specifications. If a GraphQL API supports `multipart/mixed` responses for deferred and streamed delivery of GraphQL results, `@urql/core` (and all its derived fetch implementations) will attempt to stream results. This is _only supported_ on browsers [supporting streamed fetch responses](https://developer.mozilla.org/en-US/docs/Web/API/Response/body), which excludes IE11.
+The implementation of streamed multipart responses is derived from [`meros` by `@maraisr`](https://github.com/maraisr/meros), and is subject to change if the RFCs end up changing.
diff --git a/.changeset/unlucky-dogs-repair.md b/.changeset/unlucky-dogs-repair.md
new file mode 100644
index 00000000..1441f503
--- /dev/null
+++ b/.changeset/unlucky-dogs-repair.md
@@ -0,0 +1,5 @@
+---
+'@urql/exchange-execute': minor
+---
+
+Support async iterated results, including subscriptions via `AsyncIterator` support and `@defer` / `@stream` if the appropriate version of `graphql` is used, e.g. `15.4.0-experimental-stream-defer.1`.
diff --git a/docs/comparison.md b/docs/comparison.md
index 2bc97a8c..11a56f0e 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -51,7 +51,7 @@ All features are marked to indicate the following:
 | Persisted Queries                          | ✅ `@urql/exchange-persisted-fetch` | ✅ `apollo-link-persisted-queries`            | ✅                             |
 | Batched Queries                            | 🛑                                  | ✅ `apollo-link-batch-http`                   | 🟡 `react-relay-network-layer` |
 | Live Queries                               | 🛑                                  | 🛑                                            | ✅                             |
-| Defer & Stream Directives                  | 🛑                                  | 🛑                                            | ✅                             |
+| Defer & Stream Directives                  | ✅                                  | 🛑                                            | 🟡 (unreleased)                |
 | Switching to `GET` method                  | ✅                                  | ✅                                            | 🟡 `react-relay-network-layer` |
 | File Uploads                               | ✅ `@urql/exchange-multipart-fetch` | 🟡 `apollo-upload-client`                     | 🛑                             |
 | Retrying Failed Queries                    | ✅ `@urql/exchange-retry`           | ✅ `apollo-link-retry`                        | ✅ `DefaultNetworkLayer`       |
diff --git a/examples/README.md b/examples/README.md
index d2e184cf..53cd735b 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,14 +1,15 @@
-| Example                                                      | Description                                                                                                  |
-| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
-| [`with-react`](./with-react)                                 | Shows a basic query in `urql` with React.                                                                    |
-| [`with-react-native`](./with-react-native)                   | Shows a basic query in `urql` with React Native.                                                             |
-| [`with-svelte`](./with-svelte)                               | Shows a basic query in `@urql/svelte` with Svelte.                                                           |
-| [`with-vue3`](./with-vue3)                                   | Shows a basic query in `@urql/vue` with Vue 3.                                                               |
-| [`with-next`](./with-next)                                   | Shows some examples with `next-urql` in Next.js with the default, `getStaticProps` and `getServerSideProps`. |
-| [`with-pagination`](./with-pagination)                       | Shows how to generically set up infinite pagination with `urql` in UI code.                                  |
-| [`with-apq`](./with-apq)                                     | Shows Automatic Persisted Queries with `@urql/exchange-persisted-fetch`.                                     |
-| [`with-graphcache-updates`](./with-graphcache-updates)       | Shows manual cache updates with `@urql/exchange-graphcache`.                                                 |
-| [`with-graphcache-pagination`](./with-graphcache-pagination) | Shows the automatic infinite pagination helpers from `@urql/exchange-graphcache`.                            |
-| [`with-multipart`](./with-multipart)                         | Shows file upload support with `@urql/exchange-multipart-fetch`.                                             |
-| [`with-refresh-auth`](./with-refresh-auth)                   | Shows an example of authentication with refresh tokens using `@urql/exchange-auth`.                          |
-| [`with-retry`](./with-retry)                                 | Shows how to set up `@urql/exchange-retry` for retrying failed operations.                                   |
+| Example                                                          | Description                                                                                                  |
+| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
+| [`with-react`](./with-react)                                     | Shows a basic query in `urql` with React.                                                                    |
+| [`with-react-native`](./with-react-native)                       | Shows a basic query in `urql` with React Native.                                                             |
+| [`with-svelte`](./with-svelte)                                   | Shows a basic query in `@urql/svelte` with Svelte.                                                           |
+| [`with-vue3`](./with-vue3)                                       | Shows a basic query in `@urql/vue` with Vue 3.                                                               |
+| [`with-next`](./with-next)                                       | Shows some examples with `next-urql` in Next.js with the default, `getStaticProps` and `getServerSideProps`. |
+| [`with-pagination`](./with-pagination)                           | Shows how to generically set up infinite pagination with `urql` in UI code.                                  |
+| [`with-apq`](./with-apq)                                         | Shows Automatic Persisted Queries with `@urql/exchange-persisted-fetch`.                                     |
+| [`with-graphcache-updates`](./with-graphcache-updates)           | Shows manual cache updates with `@urql/exchange-graphcache`.                                                 |
+| [`with-graphcache-pagination`](./with-graphcache-pagination)     | Shows the automatic infinite pagination helpers from `@urql/exchange-graphcache`.                            |
+| [`with-multipart`](./with-multipart)                             | Shows file upload support with `@urql/exchange-multipart-fetch`.                                             |
+| [`with-refresh-auth`](./with-refresh-auth)                       | Shows an example of authentication with refresh tokens using `@urql/exchange-auth`.                          |
+| [`with-retry`](./with-retry)                                     | Shows how to set up `@urql/exchange-retry` for retrying failed operations.                                   |
+| [`with-defer-stream-directives`](./with-defer-stream-directives) | Demonstrates `urql` and `@urql/exchange-graphcache` with built-in support for `@defer` and `@stream`.        |
diff --git a/examples/with-defer-stream-directives/README.md b/examples/with-defer-stream-directives/README.md
new file mode 100644
index 00000000..c7d93345
--- /dev/null
+++ b/examples/with-defer-stream-directives/README.md
@@ -0,0 +1,20 @@
+# With `@defer` / `@stream` Directives
+
+This example shows `urql` in use [with `@defer` and `@stream`
+directives](https://graphql.org/blog/2020-12-08-improving-latency-with-defer-and-stream-directives)
+in GraphQL.
+
+To run this example install dependencies and run the `start` script:
+
+```sh
+yarn install
+yarn run start
+# or
+npm install
+npm run start
+```
+
+This example contains:
+
+- The `urql` bindings and a React app with a client set up in [`src/App.jsx`](src/App.jsx)
+- A local `polka` server set up to test deferred and streamed results in [`server/`](server/).
diff --git a/examples/with-defer-stream-directives/index.html b/examples/with-defer-stream-directives/index.html
new file mode 100644
index 00000000..48b691b0
--- /dev/null
+++ b/examples/with-defer-stream-directives/index.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>with-defer-stream-directives</title>
+    <style>
+      body {
+        margin: 0;
+        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
+          'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
+          'Helvetica Neue', sans-serif;
+        -webkit-font-smoothing: antialiased;
+        -moz-osx-font-smoothing: grayscale;
+      }
+
+      code {
+        font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+          monospace;
+      }
+    </style>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/index.jsx"></script>
+  </body>
+</html>
diff --git a/examples/with-defer-stream-directives/package.json b/examples/with-defer-stream-directives/package.json
new file mode 100644
index 00000000..dad64726
--- /dev/null
+++ b/examples/with-defer-stream-directives/package.json
@@ -0,0 +1,25 @@
+{
+  "name": "with-defer-stream-directives",
+  "version": "0.0.0",
+  "private": true,
+  "scripts": {
+    "start": "concurrently -k \"vite\" \"node server/index.js\""
+  },
+  "dependencies": {
+    "@urql/core": "^2.3.0",
+    "@urql/exchange-graphcache": "^4.3.0",
+    "graphql": "15.4.0-experimental-stream-defer.1",
+    "react": "^17.0.2",
+    "react-dom": "^17.0.2",
+    "urql": "^2.0.2"
+  },
+  "devDependencies": {
+    "@polka/parse": "^1.0.0-next.0",
+    "@vitejs/plugin-react-refresh": "^1.3.3",
+    "concurrently": "^6.2.1",
+    "cors": "^2.8.5",
+    "graphql-helix": "^1.7.0",
+    "polka": "^0.5.2",
+    "vite": "^2.2.4"
+  }
+}
diff --git a/examples/with-defer-stream-directives/server/index.js b/examples/with-defer-stream-directives/server/index.js
new file mode 100644
index 00000000..18c7f63a
--- /dev/null
+++ b/examples/with-defer-stream-directives/server/index.js
@@ -0,0 +1,129 @@
+// Credits to https://github.com/maraisr/meros/blob/main/examples/relay-with-helix/server.js
+
+/* eslint-disable @typescript-eslint/no-var-requires, es5/no-generators, no-console */
+const polka = require('polka');
+const { json } = require('@polka/parse');
+const cors = require('cors')();
+const { getGraphQLParameters, processRequest } = require('graphql-helix');
+const {
+  GraphQLList,
+  GraphQLObjectType,
+  GraphQLSchema,
+  GraphQLString,
+} = require('graphql');
+
+const schema = new GraphQLSchema({
+  query: new GraphQLObjectType({
+    name: 'Query',
+    fields: () => ({
+      alphabet: {
+        type: new GraphQLList(
+          new GraphQLObjectType({
+            name: 'Alphabet',
+            fields: {
+              char: {
+                type: GraphQLString,
+              },
+            },
+          })
+        ),
+        resolve: async function* () {
+          for (let letter = 65; letter <= 90; letter++) {
+            await new Promise(resolve => setTimeout(resolve, 500));
+            yield { char: String.fromCharCode(letter) };
+          }
+        },
+      },
+      song: {
+        type: new GraphQLObjectType({
+          name: 'Song',
+          fields: () => ({
+            firstVerse: {
+              type: GraphQLString,
+              resolve: () => "Now I know my ABC's.",
+            },
+            secondVerse: {
+              type: GraphQLString,
+              resolve: () =>
+                new Promise(resolve =>
+                  setTimeout(
+                    () => resolve("Next time won't you sing with me?"),
+                    5000
+                  )
+                ),
+            },
+          }),
+        }),
+        resolve: () =>
+          new Promise(resolve => setTimeout(() => resolve('goodbye'), 1000)),
+      },
+    }),
+  }),
+});
+
+polka()
+  .use(cors, json())
+  .use('/graphql', async (req, res) => {
+    const request = {
+      body: req.body,
+      headers: req.headers,
+      method: req.method,
+      query: req.query,
+    };
+
+    let { operationName, query, variables } = getGraphQLParameters(request);
+
+    // BEWARE HACKS
+    query = query.replace('initial_count', 'initialCount');
+
+    const result = await processRequest({
+      operationName,
+      query,
+      variables,
+      request,
+      schema,
+    });
+
+    if (result.type === 'RESPONSE') {
+      result.headers.forEach(({ name, value }) => res.setHeader(name, value));
+      res.writeHead(result.status, {
+        'Content-Type': 'application/json',
+      });
+      res.end(JSON.stringify(result.payload));
+    } else if (result.type === 'MULTIPART_RESPONSE') {
+      res.writeHead(200, {
+        Connection: 'keep-alive',
+        'Content-Type': 'multipart/mixed; boundary="-"',
+        'Transfer-Encoding': 'chunked',
+      });
+
+      req.on('close', () => {
+        result.unsubscribe();
+      });
+
+      res.write('---');
+
+      await result.subscribe(result => {
+        const chunk = Buffer.from(JSON.stringify(result), 'utf8');
+        const data = [
+          '',
+          'Content-Type: application/json; charset=utf-8',
+          '',
+          chunk,
+        ];
+
+        if (result.hasNext) {
+          data.push('---');
+        }
+
+        res.write(data.join('\r\n'));
+      });
+
+      res.write('\r\n-----\r\n');
+      res.end();
+    }
+  })
+  .listen(3004, err => {
+    if (err) throw err;
+    console.log(`> Running on localhost:3004`);
+  });
diff --git a/examples/with-defer-stream-directives/src/App.jsx b/examples/with-defer-stream-directives/src/App.jsx
new file mode 100644
index 00000000..5037b1e0
--- /dev/null
+++ b/examples/with-defer-stream-directives/src/App.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import {
+  createClient,
+  Provider,
+  dedupExchange,
+  debugExchange,
+  fetchExchange,
+} from 'urql';
+
+import { cacheExchange } from '@urql/exchange-graphcache';
+
+import Songs from './Songs';
+
+const cache = cacheExchange({
+  keys: {
+    Alphabet: data => data.char,
+    Song: () => null,
+  },
+});
+
+const client = createClient({
+  url: 'http://localhost:3004/graphql',
+  exchanges: [dedupExchange, cache, debugExchange, fetchExchange],
+});
+
+function App() {
+  return (
+    <Provider value={client}>
+      <Songs />
+    </Provider>
+  );
+}
+
+export default App;
diff --git a/examples/with-defer-stream-directives/src/Songs.jsx b/examples/with-defer-stream-directives/src/Songs.jsx
new file mode 100644
index 00000000..c848a373
--- /dev/null
+++ b/examples/with-defer-stream-directives/src/Songs.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { gql, useQuery } from 'urql';
+
+const SecondVerseFragment = gql`
+  fragment secondVerseFields on Song {
+    secondVerse
+  }
+`;
+
+const SONGS_QUERY = gql`
+  query App_Query {
+    song {
+      firstVerse
+      ...secondVerseFields @defer
+    }
+    alphabet @stream(initial_count: 3) {
+      char
+    }
+  }
+
+  ${SecondVerseFragment}
+`;
+
+const Song = React.memo(function Song({ song }) {
+  return (
+    <section>
+      <p>{song.firstVerse}</p>
+      <p>{song.secondVerse}</p>
+    </section>
+  );
+});
+
+const LocationsList = () => {
+  const [result] = useQuery({
+    query: SONGS_QUERY,
+  });
+
+  const { data } = result;
+
+  return (
+    <div>
+      {data && (
+        <>
+          <Song song={data.song} />
+          {data.alphabet.map(i => (
+            <div key={i.char}>{i.char}</div>
+          ))}
+        </>
+      )}
+    </div>
+  );
+};
+
+export default LocationsList;
diff --git a/examples/with-defer-stream-directives/src/index.jsx b/examples/with-defer-stream-directives/src/index.jsx
new file mode 100644
index 00000000..f16bd3d1
--- /dev/null
+++ b/examples/with-defer-stream-directives/src/index.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import { render } from 'react-dom';
+
+import App from './App';
+
+render(
+  <React.StrictMode>
+    <App />
+  </React.StrictMode>,
+  document.getElementById('root')
+);
diff --git a/examples/with-defer-stream-directives/vite.config.js b/examples/with-defer-stream-directives/vite.config.js
new file mode 100644
index 00000000..75f50ede
--- /dev/null
+++ b/examples/with-defer-stream-directives/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite';
+import reactRefresh from '@vitejs/plugin-react-refresh';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [reactRefresh()],
+});
diff --git a/exchanges/execute/src/execute.ts b/exchanges/execute/src/execute.ts
index e92942ec..f67e6406 100644
--- a/exchanges/execute/src/execute.ts
+++ b/exchanges/execute/src/execute.ts
@@ -1,12 +1,12 @@
 import {
+  Source,
   pipe,
   share,
   filter,
-  fromPromise,
   takeUntil,
-  onEnd,
   mergeMap,
   merge,
+  make,
 } from 'wonka';
 
 import {
@@ -18,9 +18,12 @@ import {
 
 import {
   Exchange,
+  ExecutionResult,
   makeResult,
   makeErrorResult,
+  mergeResultPatch,
   Operation,
+  OperationResult,
   getOperationName,
 } from '@urql/core';
 
@@ -32,6 +35,72 @@ export interface ExecuteExchangeArgs {
   typeResolver?: GraphQLTypeResolver<any, any>;
 }
 
+type ExecuteParams = Parameters<typeof execute>;
+
+const asyncIterator =
+  typeof Symbol !== 'undefined' ? Symbol.asyncIterator : null;
+
+const makeExecuteSource = (
+  operation: Operation,
+  args: ExecuteParams
+): Source<OperationResult> => {
+  return make<OperationResult>(observer => {
+    let ended = false;
+
+    Promise.resolve()
+      .then(() => {
+        if (ended) return;
+        return execute(...args) as any;
+      })
+      .then((result: ExecutionResult | AsyncIterable<ExecutionResult>) => {
+        if (ended || !result) {
+          return;
+        } else if (!asyncIterator || !result[asyncIterator]) {
+          observer.next(makeResult(operation, result as ExecutionResult));
+          return;
+        }
+
+        const iterator: AsyncIterator<ExecutionResult> = result[
+          asyncIterator!
+        ]();
+        let prevResult: OperationResult | null = null;
+
+        function next({
+          done,
+          value,
+        }: {
+          done?: boolean;
+          value: ExecutionResult;
+        }) {
+          if (value) {
+            observer.next(
+              (prevResult = prevResult
+                ? mergeResultPatch(prevResult, value)
+                : makeResult(operation, value))
+            );
+          }
+
+          if (!done && !ended) {
+            return iterator.next().then(next);
+          }
+        }
+
+        return iterator.next().then(next);
+      })
+      .then(() => {
+        observer.complete();
+      })
+      .catch(error => {
+        observer.next(makeErrorResult(operation, error));
+        observer.complete();
+      });
+
+    return () => {
+      ended = true;
+    };
+  });
+};
+
 /** Exchange for executing queries locally on a schema using graphql-js. */
 export const executeExchange = ({
   schema,
@@ -57,32 +126,17 @@ export const executeExchange = ({
 
         const calculatedContext =
           typeof context === 'function' ? context(operation) : context;
-
-        let ended = false;
-
-        const result = Promise.resolve()
-          .then(() => {
-            if (ended) return;
-
-            return execute(
-              schema,
-              operation.query,
-              rootValue,
-              calculatedContext,
-              operation.variables,
-              getOperationName(operation.query),
-              fieldResolver,
-              typeResolver
-            );
-          })
-          .then(result => makeResult(operation, result))
-          .catch(err => makeErrorResult(operation, err));
-
         return pipe(
-          fromPromise(result),
-          onEnd(() => {
-            ended = true;
-          }),
+          makeExecuteSource(operation, [
+            schema,
+            operation.query,
+            rootValue,
+            calculatedContext,
+            operation.variables,
+            getOperationName(operation.query),
+            fieldResolver,
+            typeResolver,
+          ]),
           takeUntil(teardown$)
         );
       })
@@ -90,9 +144,7 @@ export const executeExchange = ({
 
     const forwardedOps$ = pipe(
       sharedOps$,
-      filter((operation: Operation) => {
-        return operation.kind !== 'query' && operation.kind !== 'mutation';
-      }),
+      filter(operation => operation.kind === 'teardown'),
       forward
     );
 
diff --git a/exchanges/graphcache/src/ast/traversal.ts b/exchanges/graphcache/src/ast/traversal.ts
index 463ed337..1a45d357 100644
--- a/exchanges/graphcache/src/ast/traversal.ts
+++ b/exchanges/graphcache/src/ast/traversal.ts
@@ -2,6 +2,8 @@ import {
   SelectionNode,
   DocumentNode,
   OperationDefinitionNode,
+  FragmentSpreadNode,
+  InlineFragmentNode,
   valueFromASTUntyped,
   Kind,
 } from 'graphql';
@@ -42,18 +44,15 @@ export const getFragments = (doc: DocumentNode): Fragments => {
   return fragments;
 };
 
+/** Resolves @include and @skip directives to determine whether field is included. */
 export const shouldInclude = (
   node: SelectionNode,
   vars: Variables
 ): boolean => {
-  const { directives } = node;
-  if (!directives) return true;
-
   // Finds any @include or @skip directive that forces the node to be skipped
-  for (let i = 0, l = directives.length; i < l; i++) {
-    const directive = directives[i];
+  for (let i = 0; node.directives && i < node.directives.length; i++) {
+    const directive = node.directives[i];
     const name = getName(directive);
-
     if (
       (name === 'include' || name === 'skip') &&
       directive.arguments &&
@@ -69,3 +68,31 @@ export const shouldInclude = (
 
   return true;
 };
+
+/** Resolves @defer directive to determine whether a fragment is potentially skipped. */
+export const isDeferred = (
+  node: FragmentSpreadNode | InlineFragmentNode,
+  vars: Variables
+): boolean => {
+  for (let i = 0; node.directives && i < node.directives.length; i++) {
+    const directive = node.directives[i];
+    const name = getName(directive);
+    if (name === 'defer') {
+      for (
+        let j = 0;
+        directive.arguments && j < directive.arguments.length;
+        j++
+      ) {
+        const argument = directive.arguments[i];
+        if (getName(argument) === 'if') {
+          // Return whether `@defer(if: )` is enabled
+          return !!valueFromASTUntyped(argument.value, vars);
+        }
+      }
+
+      return true;
+    }
+  }
+
+  return false;
+};
diff --git a/exchanges/graphcache/src/cacheExchange.ts b/exchanges/graphcache/src/cacheExchange.ts
index 36ec3831..1aea4a1f 100644
--- a/exchanges/graphcache/src/cacheExchange.ts
+++ b/exchanges/graphcache/src/cacheExchange.ts
@@ -198,10 +198,10 @@ export const cacheExchange = <C extends Partial<CacheExchangeOpts>>(
       const dependencies = optimisticKeysToDependencies.get(key);
       collectPendingOperations(pendingOperations, dependencies);
       optimisticKeysToDependencies.delete(key);
-    } else {
-      reserveLayer(store.data, operation.key);
     }
 
+    reserveLayer(store.data, operation.key, result.hasNext);
+
     let queryDependencies: void | Dependencies;
     let data: Data | null = result.data;
     if (data) {
@@ -322,6 +322,7 @@ export const cacheExchange = <C extends Partial<CacheExchangeOpts>>(
               outcome === 'partial')
           ) {
             result.stale = true;
+            result.hasNext = true;
             if (!isBlockedByOptimisticUpdate(dependencies)) {
               client.reexecuteOperation(
                 toRequestPolicy(operation, 'network-only')
diff --git a/exchanges/graphcache/src/operations/query.ts b/exchanges/graphcache/src/operations/query.ts
index 1abdff46..4ca76d30 100644
--- a/exchanges/graphcache/src/operations/query.ts
+++ b/exchanges/graphcache/src/operations/query.ts
@@ -44,6 +44,7 @@ import {
   makeContext,
   updateContext,
   getFieldError,
+  deferRef,
 } from './shared';
 
 import {
@@ -152,7 +153,7 @@ const readRoot = (
 
     // Check for any referential changes in the field's value
     hasChanged = hasChanged || dataFieldValue !== output[fieldAlias];
-    output[fieldAlias] = dataFieldValue!;
+    if (dataFieldValue !== undefined) output[fieldAlias] = dataFieldValue!;
 
     // After processing the field, remove the current alias from the path again
     ctx.__internal.path.pop();
@@ -416,7 +417,10 @@ const readSelection = (
     // Now that dataFieldValue has been retrieved it'll be set on data
     // If it's uncached (undefined) but nullable we can continue assembling
     // a partial query result
-    if (
+    if (dataFieldValue === undefined && deferRef.current) {
+      // The field is undelivered and uncached, but is included in a deferred fragment
+      hasFields = true;
+    } else if (
       dataFieldValue === undefined &&
       ((store.schema && isFieldNullable(store.schema, typename, fieldName)) ||
         !!getFieldError(ctx))
@@ -424,6 +428,10 @@ const readSelection = (
       // The field is uncached or has errored, so it'll be set to null and skipped
       hasPartials = true;
       dataFieldValue = null;
+    } else if (dataFieldValue === undefined) {
+      // If the field isn't deferred or partial then we have to abort
+      ctx.__internal.path.pop();
+      return undefined;
     } else {
       // Otherwise continue as usual
       hasFields = hasFields || fieldName !== '__typename';
@@ -431,14 +439,9 @@ const readSelection = (
 
     // After processing the field, remove the current alias from the path again
     ctx.__internal.path.pop();
-    // Return undefined immediately when a field is uncached
-    if (dataFieldValue === undefined) {
-      return undefined;
-    }
-
     // Check for any referential changes in the field's value
     hasChanged = hasChanged || dataFieldValue !== output[fieldAlias];
-    output[fieldAlias] = dataFieldValue;
+    if (dataFieldValue !== undefined) output[fieldAlias] = dataFieldValue;
   }
 
   ctx.partial = ctx.partial || hasPartials;
diff --git a/exchanges/graphcache/src/operations/shared.ts b/exchanges/graphcache/src/operations/shared.ts
index 7931dc58..a90013c3 100644
--- a/exchanges/graphcache/src/operations/shared.ts
+++ b/exchanges/graphcache/src/operations/shared.ts
@@ -7,6 +7,7 @@ import {
 } from 'graphql';
 
 import {
+  isDeferred,
   isInlineFragment,
   getTypeCondition,
   getSelectionSet,
@@ -18,6 +19,7 @@ import {
 import { warn, pushDebugNode, popDebugNode } from '../helpers/help';
 import { hasField } from '../store/data';
 import { Store, keyOfField } from '../store';
+
 import { getFieldArguments, shouldInclude, isInterfaceOfType } from '../ast';
 
 import {
@@ -49,6 +51,7 @@ export interface Context {
 }
 
 export const contextRef: { current: Context | null } = { current: null };
+export const deferRef: { current: boolean } = { current: false };
 
 // Checks whether the current data field is a cache miss because of a GraphQLError
 export const getFieldError = (ctx: Context): GraphQLError | undefined =>
@@ -155,17 +158,21 @@ export const makeSelectionIterator = (
   select: SelectionSet,
   ctx: Context
 ): SelectionIterator => {
+  let childDeferred = false;
   let childIterator: SelectionIterator | void;
   let index = 0;
 
   return function next() {
-    if (childIterator !== undefined) {
+    if (!deferRef.current && childDeferred) deferRef.current = childDeferred;
+
+    if (childIterator) {
       const node = childIterator();
-      if (node !== undefined) {
+      if (node != null) {
         return node;
       }
 
       childIterator = undefined;
+      childDeferred = false;
       if (process.env.NODE_ENV !== 'production') {
         popDebugNode();
       }
@@ -190,12 +197,15 @@ export const makeSelectionIterator = (
                 entityKey,
                 ctx.variables
               );
-
           if (isMatching) {
             if (process.env.NODE_ENV !== 'production') {
               pushDebugNode(typename, fragmentNode);
             }
 
+            childDeferred = !!isDeferred(node, ctx.variables);
+            if (!deferRef.current && childDeferred)
+              deferRef.current = childDeferred;
+
             return (childIterator = makeSelectionIterator(
               typename,
               entityKey,
diff --git a/exchanges/graphcache/src/operations/write.ts b/exchanges/graphcache/src/operations/write.ts
index 2e2244e3..ef7c6a6d 100644
--- a/exchanges/graphcache/src/operations/write.ts
+++ b/exchanges/graphcache/src/operations/write.ts
@@ -44,6 +44,7 @@ import {
   makeContext,
   updateContext,
   getFieldError,
+  deferRef,
 } from './shared';
 
 export interface WriteResult {
@@ -204,8 +205,9 @@ const writeSelection = (
     const fieldAlias = getFieldAlias(node);
     let fieldValue = data[fieldAlias];
 
+    // Development check of undefined fields
     if (process.env.NODE_ENV !== 'production') {
-      if (!isRoot && fieldValue === undefined) {
+      if (!isRoot && fieldValue === undefined && !deferRef.current) {
         const advice = ctx.optimistic
           ? '\nYour optimistic result may be missing a field!'
           : '';
@@ -231,8 +233,14 @@ const writeSelection = (
       }
     }
 
-    // We simply skip all typenames fields and assume they've already been written above
-    if (fieldName === '__typename') continue;
+    if (
+      // Skip typename fields and assume they've already been written above
+      fieldName === '__typename' ||
+      // Fields marked as deferred that aren't defined must be skipped
+      (fieldValue === undefined && deferRef.current)
+    ) {
+      continue;
+    }
 
     // Add the current alias to the walked path before processing the field's value
     ctx.__internal.path.push(fieldAlias);
diff --git a/exchanges/graphcache/src/store/data.ts b/exchanges/graphcache/src/store/data.ts
index b8270640..174decb0 100644
--- a/exchanges/graphcache/src/store/data.ts
+++ b/exchanges/graphcache/src/store/data.ts
@@ -47,6 +47,8 @@ export interface InMemoryData {
   records: NodeMap<EntityField>;
   /** A map of entity links which are connections from one entity to another (key-value entries per entity) */
   links: NodeMap<Link>;
+  /** A set of Query operation keys that are in-flight and deferred/streamed */
+  deferredKeys: Set<number>;
   /** A set of Query operation keys that are in-flight and awaiting a result */
   commutativeKeys: Set<number>;
   /** The order of optimistic layers */
@@ -150,7 +152,8 @@ export const clearDataState = () => {
     while (
       --i >= 0 &&
       data.refLock[data.optimisticOrder[i]] &&
-      data.commutativeKeys.has(data.optimisticOrder[i])
+      data.commutativeKeys.has(data.optimisticOrder[i]) &&
+      !data.deferredKeys.has(data.optimisticOrder[i])
     ) {
       squashLayer(data.optimisticOrder[i]);
     }
@@ -184,6 +187,7 @@ export const noopDataState = (
   layerKey: number | null,
   isOptimistic?: boolean
 ) => {
+  if (layerKey && !isOptimistic) data.deferredKeys.delete(layerKey);
   initDataState('read', data, layerKey, isOptimistic);
   clearDataState();
 };
@@ -222,6 +226,7 @@ export const make = (queryRootKey: string): InMemoryData => ({
   refLock: makeDict(),
   links: makeNodeMap(),
   records: makeNodeMap(),
+  deferredKeys: new Set(),
   commutativeKeys: new Set(),
   optimisticOrder: [],
   storage: null,
@@ -499,7 +504,11 @@ export const writeLink = (
 };
 
 /** Reserves an optimistic layer and preorders it */
-export const reserveLayer = (data: InMemoryData, layerKey: number) => {
+export const reserveLayer = (
+  data: InMemoryData,
+  layerKey: number,
+  hasNext?: boolean
+) => {
   const index = data.optimisticOrder.indexOf(layerKey);
   if (index === -1) {
     // The new layer needs to be reserved in front of all other commutative
@@ -515,6 +524,12 @@ export const reserveLayer = (data: InMemoryData, layerKey: number) => {
     data.optimisticOrder.unshift(layerKey);
   }
 
+  if (hasNext) {
+    data.deferredKeys.add(layerKey);
+  } else {
+    data.deferredKeys.delete(layerKey);
+  }
+
   data.commutativeKeys.add(layerKey);
 };
 
@@ -537,6 +552,7 @@ const clearLayer = (data: InMemoryData, layerKey: number) => {
     delete data.refLock[layerKey];
     delete data.records.optimistic[layerKey];
     delete data.links.optimistic[layerKey];
+    data.deferredKeys.delete(layerKey);
   }
 };
 
diff --git a/packages/core/src/exchanges/dedup.ts b/packages/core/src/exchanges/dedup.ts
index c1e701cd..6066fa51 100644
--- a/packages/core/src/exchanges/dedup.ts
+++ b/packages/core/src/exchanges/dedup.ts
@@ -30,8 +30,10 @@ export const dedupExchange: Exchange = ({ forward, dispatchDebug }) => {
     return !isInFlight;
   };
 
-  const afterOperationResult = ({ operation }: OperationResult) => {
-    inFlightKeys.delete(operation.key);
+  const afterOperationResult = ({ operation, hasNext }: OperationResult) => {
+    if (!hasNext) {
+      inFlightKeys.delete(operation.key);
+    }
   };
 
   return ops$ => {
diff --git a/packages/core/src/exchanges/ssr.ts b/packages/core/src/exchanges/ssr.ts
index 54aad5fa..f24a199c 100644
--- a/packages/core/src/exchanges/ssr.ts
+++ b/packages/core/src/exchanges/ssr.ts
@@ -5,6 +5,7 @@ import { CombinedError } from '../utils';
 import { reexecuteOperation } from './cache';
 
 export interface SerializedResult {
+  hasNext?: boolean;
   data?: string | undefined; // JSON string of data
   error?: {
     graphQLErrors: Array<Partial<GraphQLError> | string>;
@@ -29,19 +30,15 @@ export interface SSRExchange extends Exchange {
   extractData(): SSRData;
 }
 
-const shouldSkip = ({ kind }: Operation) =>
-  kind !== 'subscription' && kind !== 'query';
-
 /** Serialize an OperationResult to plain JSON */
 const serializeResult = ({
+  hasNext,
   data,
   error,
 }: OperationResult): SerializedResult => {
   const result: SerializedResult = {};
-
-  if (data !== undefined) {
-    result.data = JSON.stringify(data);
-  }
+  if (data !== undefined) result.data = JSON.stringify(data);
+  if (hasNext) result.hasNext = true;
 
   if (error) {
     result.error = {
@@ -68,28 +65,20 @@ const serializeResult = ({
 const deserializeResult = (
   operation: Operation,
   result: SerializedResult
-): OperationResult => {
-  const { error, data: dataJson } = result;
-
-  const deserialized: OperationResult = {
-    operation,
-    data: dataJson ? JSON.parse(dataJson) : undefined,
-    extensions: undefined,
-    error: error
-      ? new CombinedError({
-          networkError: error.networkError
-            ? new Error(error.networkError)
-            : undefined,
-          graphQLErrors:
-            error.graphQLErrors && error.graphQLErrors.length
-              ? error.graphQLErrors
-              : undefined,
-        })
-      : undefined,
-  };
-
-  return deserialized;
-};
+): OperationResult => ({
+  operation,
+  data: result.data ? JSON.parse(result.data) : undefined,
+  extensions: undefined,
+  error: result.error
+    ? new CombinedError({
+        networkError: result.error.networkError
+          ? new Error(result.error.networkError)
+          : undefined,
+        graphQLErrors: result.error.graphQLErrors,
+      })
+    : undefined,
+  hasNext: result.hasNext,
+});
 
 /** The ssrExchange can be created to capture data during SSR and also to rehydrate it on the client */
 export const ssrExchange = (params?: SSRExchangeParams): SSRExchange => {
@@ -111,10 +100,6 @@ export const ssrExchange = (params?: SSRExchangeParams): SSRExchange => {
     }
   };
 
-  const isCached = (operation: Operation) => {
-    return !shouldSkip(operation) && data[operation.key] != null;
-  };
-
   // The SSR Exchange is a temporary cache that can populate results into data for suspense
   // On the client it can be used to retrieve these temporary results from a rehydrated cache
   const ssr: SSRExchange = ({ client, forward }) => ops$ => {
@@ -129,7 +114,9 @@ export const ssrExchange = (params?: SSRExchangeParams): SSRExchange => {
 
     let forwardedOps$ = pipe(
       sharedOps$,
-      filter(op => !isCached(op)),
+      filter(
+        operation => !data[operation.key] || !!data[operation.key]!.hasNext
+      ),
       forward
     );
 
@@ -137,7 +124,7 @@ export const ssrExchange = (params?: SSRExchangeParams): SSRExchange => {
     // it once, cachedOps$ needs to be merged after forwardedOps$
     let cachedOps$ = pipe(
       sharedOps$,
-      filter(op => isCached(op)),
+      filter(operation => !!data[operation.key]),
       map(op => {
         const serialized = data[op.key]!;
         const result = deserializeResult(op, serialized);
@@ -156,7 +143,7 @@ export const ssrExchange = (params?: SSRExchangeParams): SSRExchange => {
         forwardedOps$,
         tap((result: OperationResult) => {
           const { operation } = result;
-          if (!shouldSkip(operation)) {
+          if (operation.kind !== 'mutation') {
             const serialized = serializeResult(result);
             data[operation.key] = serialized;
           }
diff --git a/packages/core/src/exchanges/subscription.ts b/packages/core/src/exchanges/subscription.ts
index 7045896b..a8d4464f 100644
--- a/packages/core/src/exchanges/subscription.ts
+++ b/packages/core/src/exchanges/subscription.ts
@@ -45,7 +45,7 @@ export interface SubscriptionOperation {
 
 export type SubscriptionForwarder = (
   operation: SubscriptionOperation
-) => ObservableLike<ExecutionResult & { extensions?: Record<string, any> }>;
+) => ObservableLike<ExecutionResult>;
 
 /** This is called to create a subscription and needs to be hooked up to a transport client. */
 export interface SubscriptionExchangeOpts {
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 21671c39..b8d1c080 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -11,6 +11,7 @@ export {
   createRequest,
   makeResult,
   makeErrorResult,
+  mergeResultPatch,
   formatDocument,
   maskTypename,
   makeOperation,
diff --git a/packages/core/src/internal/fetchSource.ts b/packages/core/src/internal/fetchSource.ts
index 1903b73b..ff07181d 100644
--- a/packages/core/src/internal/fetchSource.ts
+++ b/packages/core/src/internal/fetchSource.ts
@@ -1,68 +1,157 @@
+import { Source, make } from 'wonka';
 import { Operation, OperationResult } from '../types';
-import { makeResult, makeErrorResult } from '../utils';
-import { make } from 'wonka';
+import { makeResult, makeErrorResult, mergeResultPatch } from '../utils';
 
-const executeFetch = (
+const asyncIterator =
+  typeof Symbol !== 'undefined' ? Symbol.asyncIterator : null;
+const decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
+const jsonHeaderRe = /content-type:[^\r\n]*application\/json/i;
+const boundaryHeaderRe = /boundary="?([^=";]+)"?/i;
+
+type ChunkData = { done: false; value: Buffer | Uint8Array } | { done: true };
+
+const toString = (input: Buffer | ArrayBuffer): string =>
+  typeof Buffer !== 'undefined' && Buffer.isBuffer(input)
+    ? input.toString()
+    : decoder!.decode(input);
+
+// DERIVATIVE: Copyright (c) 2021 Marais Rossouw <hi@marais.io>
+// See: https://github.com/maraisr/meros/blob/219fe95/src/browser.ts
+const executeIncrementalFetch = (
+  onResult: (result: OperationResult) => void,
   operation: Operation,
-  url: string,
-  fetchOptions: RequestInit
-): Promise<OperationResult> => {
-  const fetcher = operation.context.fetch;
+  response: Response
+): Promise<void> => {
+  // NOTE: Guarding against fetch polyfills here
+  const contentType =
+    (response.headers && response.headers.get('Content-Type')) || '';
+  if (!/multipart\/mixed/i.test(contentType)) {
+    return response.json().then(payload => {
+      onResult(makeResult(operation, payload, response));
+    });
+  }
 
-  let statusNotOk = false;
-  let response: Response;
-
-  return (fetcher || fetch)(url, fetchOptions)
-    .then((res: Response) => {
-      response = res;
-      statusNotOk =
-        res.status < 200 ||
-        res.status >= (fetchOptions.redirect === 'manual' ? 400 : 300);
-      return res.json();
-    })
-    .then((result: any) => {
-      if (!('data' in result) && !('errors' in result)) {
-        throw new Error('No Content');
+  let boundary = '---';
+  const boundaryHeader = contentType.match(boundaryHeaderRe);
+  if (boundaryHeader) boundary = '--' + boundaryHeader[1];
+
+  let read: () => Promise<ChunkData>;
+  let cancel = () => {
+    /*noop*/
+  };
+  if (asyncIterator && response[asyncIterator]) {
+    const iterator = response[asyncIterator]();
+    read = iterator.next.bind(iterator);
+  } else if ('body' in response && response.body) {
+    const reader = response.body.getReader();
+    cancel = reader.cancel.bind(reader);
+    read = reader.read.bind(reader);
+  } else {
+    throw new TypeError('Streaming requests unsupported');
+  }
+
+  let buffer = '';
+  let isPreamble = true;
+  let nextResult: OperationResult | null = null;
+  let prevResult: OperationResult | null = null;
+
+  function next(data: ChunkData): Promise<void> | void {
+    if (!data.done) {
+      const chunk = toString(data.value);
+      let boundaryIndex = chunk.indexOf(boundary);
+      if (boundaryIndex > -1) {
+        boundaryIndex += buffer.length;
+      } else {
+        boundaryIndex = buffer.indexOf(boundary);
       }
 
-      return makeResult(operation, result, response);
-    })
-    .catch((error: Error) => {
-      if (error.name !== 'AbortError') {
-        return makeErrorResult(
-          operation,
-          statusNotOk ? new Error(response.statusText) : error,
-          response
-        );
+      buffer += chunk;
+      while (boundaryIndex > -1) {
+        const current = buffer.slice(0, boundaryIndex);
+        const next = buffer.slice(boundaryIndex + boundary.length);
+
+        if (isPreamble) {
+          isPreamble = false;
+        } else {
+          const headersEnd = current.indexOf('\r\n\r\n') + 4;
+          const headers = current.slice(0, headersEnd);
+          const body = current.slice(headersEnd, current.lastIndexOf('\r\n'));
+
+          let payload: any;
+          if (jsonHeaderRe.test(headers)) {
+            try {
+              payload = JSON.parse(body);
+              nextResult = prevResult = prevResult
+                ? mergeResultPatch(prevResult, payload, response)
+                : makeResult(operation, payload, response);
+            } catch (_error) {}
+          }
+
+          if (next.slice(0, 2) === '--' || (payload && !payload.hasNext)) {
+            if (!prevResult)
+              return onResult(makeResult(operation, {}, response));
+            break;
+          }
+        }
+
+        buffer = next;
+        boundaryIndex = buffer.indexOf(boundary);
       }
-    }) as Promise<OperationResult>;
+    }
+
+    if (nextResult) {
+      onResult(nextResult);
+      nextResult = null;
+    }
+
+    if (!data.done && (!prevResult || prevResult.hasNext)) {
+      return read().then(next);
+    }
+  }
+
+  return read().then(next).finally(cancel);
 };
 
 export const makeFetchSource = (
   operation: Operation,
   url: string,
   fetchOptions: RequestInit
-) => {
+): Source<OperationResult> => {
+  const maxStatus = fetchOptions.redirect === 'manual' ? 400 : 300;
+  const fetcher = operation.context.fetch;
+
   return make<OperationResult>(({ next, complete }) => {
     const abortController =
       typeof AbortController !== 'undefined' ? new AbortController() : null;
+    if (abortController) {
+      fetchOptions.signal = abortController.signal;
+    }
 
     let ended = false;
+    let statusNotOk = false;
+    let response: Response;
 
     Promise.resolve()
       .then(() => {
-        if (ended) {
-          return;
-        } else if (abortController) {
-          fetchOptions.signal = abortController.signal;
-        }
-
-        return executeFetch(operation, url, fetchOptions);
+        if (ended) return;
+        return (fetcher || fetch)(url, fetchOptions);
       })
-      .then((result: OperationResult | undefined) => {
-        if (!ended) {
-          ended = true;
-          if (result) next(result);
+      .then((_response: Response | void) => {
+        if (!_response) return;
+        response = _response;
+        statusNotOk = response.status < 200 || response.status >= maxStatus;
+        return executeIncrementalFetch(next, operation, response);
+      })
+      .then(complete)
+      .catch((error: Error) => {
+        if (error.name !== 'AbortError') {
+          const result = makeErrorResult(
+            operation,
+            statusNotOk ? new Error(response.statusText) : error,
+            response
+          );
+
+          next(result);
           complete();
         }
       });
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index a61dea9c..1c2376be 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -1,10 +1,22 @@
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
-import { DocumentNode } from 'graphql';
+import { GraphQLError, DocumentNode } from 'graphql';
 import { Source } from 'wonka';
 import { Client } from './client';
 import { CombinedError } from './utils/error';
 
-export { ExecutionResult } from 'graphql';
+export type ExecutionResult =
+  | {
+      errors?: Array<string | Partial<GraphQLError> | Error>;
+      data?: null | Record<string, any>;
+      extensions?: Record<string, any>;
+      hasNext?: boolean;
+    }
+  | {
+      errors?: Array<string | Partial<GraphQLError> | Error>;
+      data: any;
+      path: (string | number)[];
+      hasNext?: boolean;
+    };
 
 export type PromisifiedSource<T = any> = Source<T> & {
   toPromise: () => Promise<T>;
@@ -71,6 +83,8 @@ export interface OperationResult<Data = any, Variables = any> {
   extensions?: Record<string, any>;
   /** Optional stale flag added by exchanges that return stale results. */
   stale?: boolean;
+  /** Optional hasNext flag indicating deferred/streamed results are following. */
+  hasNext?: boolean;
 }
 
 /** Input parameters for to an Exchange factory function. */
diff --git a/packages/core/src/utils/result.ts b/packages/core/src/utils/result.ts
index 3bc940b2..7b29bbbc 100644
--- a/packages/core/src/utils/result.ts
+++ b/packages/core/src/utils/result.ts
@@ -1,22 +1,68 @@
-import { Operation, OperationResult } from '../types';
+import { ExecutionResult, Operation, OperationResult } from '../types';
 import { CombinedError } from './error';
 
 export const makeResult = (
   operation: Operation,
-  result: any,
+  result: ExecutionResult,
   response?: any
-): OperationResult => ({
-  operation,
-  data: result.data,
-  error: Array.isArray(result.errors)
-    ? new CombinedError({
-        graphQLErrors: result.errors,
-        response,
-      })
-    : undefined,
-  extensions:
-    (typeof result.extensions === 'object' && result.extensions) || undefined,
-});
+): OperationResult => {
+  if ((!('data' in result) && !('errors' in result)) || 'path' in result) {
+    throw new Error('No Content');
+  }
+
+  return {
+    operation,
+    data: result.data,
+    error: Array.isArray(result.errors)
+      ? new CombinedError({
+          graphQLErrors: result.errors,
+          response,
+        })
+      : undefined,
+    extensions:
+      (typeof result.extensions === 'object' && result.extensions) || undefined,
+    hasNext: !!result.hasNext,
+  };
+};
+
+export const mergeResultPatch = (
+  prevResult: OperationResult,
+  patch: ExecutionResult,
+  response?: any
+): OperationResult => {
+  const result = { ...prevResult };
+  result.hasNext = !!patch.hasNext;
+
+  if (!('path' in patch)) {
+    if ('data' in patch) result.data = patch.data;
+    return result;
+  }
+
+  if (Array.isArray(patch.errors)) {
+    result.error = new CombinedError({
+      graphQLErrors: result.error
+        ? [...result.error.graphQLErrors, ...patch.errors]
+        : patch.errors,
+      response,
+    });
+  }
+
+  let part: Record<string, any> | Array<any> = (result.data = {
+    ...result.data,
+  });
+
+  let i = 0;
+  let prop: string | number;
+  while (i < patch.path.length) {
+    prop = patch.path[i++];
+    part = part[prop] = Array.isArray(part[prop])
+      ? [...part[prop]]
+      : { ...part[prop] };
+  }
+
+  Object.assign(part, patch.data);
+  return result;
+};
 
 export const makeErrorResult = (
   operation: Operation,


Test patch:
diff --git a/exchanges/execute/src/execute.test.ts b/exchanges/execute/src/execute.test.ts
index a816e89e..9052f796 100644
--- a/exchanges/execute/src/execute.test.ts
+++ b/exchanges/execute/src/execute.test.ts
@@ -148,6 +148,7 @@ describe('on success response', () => {
     expect(response).toEqual({
       operation: queryOperation,
       data: mockHttpResponseData,
+      hasNext: false,
     });
   });
 });
diff --git a/exchanges/graphcache/src/cacheExchange.test.ts b/exchanges/graphcache/src/cacheExchange.test.ts
index 88103ca0..e2c6038a 100644
--- a/exchanges/graphcache/src/cacheExchange.test.ts
+++ b/exchanges/graphcache/src/cacheExchange.test.ts
@@ -2126,4 +2126,136 @@ describe('commutativity', () => {
 
     expect(data).toHaveProperty('node.name', 'subscription b');
   });
+
+  it('applies deferred results to previous layers', () => {
+    let normalData: any;
+    let deferredData: any;
+    let combinedData: any;
+
+    const client = createClient({ url: 'http://0.0.0.0' });
+    const { source: ops$, next: nextOp } = makeSubject<Operation>();
+    const { source: res$, next: nextRes } = makeSubject<OperationResult>();
+
+    jest.spyOn(client, 'reexecuteOperation').mockImplementation(nextOp);
+
+    const normalQuery = gql`
+      {
+        node {
+          id
+          name
+        }
+      }
+    `;
+
+    const deferredQuery = gql`
+      {
+        ... @defer {
+          deferred {
+            id
+            name
+          }
+        }
+      }
+    `;
+
+    const combinedQuery = gql`
+      {
+        node {
+          id
+          name
+        }
+        ... @defer {
+          deferred {
+            id
+            name
+          }
+        }
+      }
+    `;
+
+    const forward = (ops$: Source<Operation>): Source<OperationResult> =>
+      merge([
+        pipe(
+          ops$,
+          filter(() => false)
+        ) as any,
+        res$,
+      ]);
+
+    pipe(
+      cacheExchange()({ forward, client, dispatchDebug })(ops$),
+      tap(result => {
+        if (result.operation.kind === 'query') {
+          if (result.operation.key === 1) {
+            deferredData = result.data;
+          } else if (result.operation.key === 42) {
+            combinedData = result.data;
+          } else {
+            normalData = result.data;
+          }
+        }
+      }),
+      publish
+    );
+
+    const combinedOp = client.createRequestOperation('query', {
+      key: 42,
+      query: combinedQuery,
+    });
+    const deferredOp = client.createRequestOperation('query', {
+      key: 1,
+      query: deferredQuery,
+    });
+    const normalOp = client.createRequestOperation('query', {
+      key: 2,
+      query: normalQuery,
+    });
+
+    nextOp(combinedOp);
+    nextOp(deferredOp);
+    nextOp(normalOp);
+
+    nextRes({
+      operation: deferredOp,
+      data: {
+        __typename: 'Query',
+      },
+      hasNext: true,
+    });
+
+    expect(deferredData).toHaveProperty('deferred', undefined);
+
+    nextRes({
+      operation: normalOp,
+      data: {
+        __typename: 'Query',
+        node: {
+          __typename: 'Node',
+          id: 2,
+          name: 'normal',
+        },
+      },
+    });
+
+    expect(normalData).toHaveProperty('node.id', 2);
+    expect(combinedData).toHaveProperty('deferred', undefined);
+    expect(combinedData).toHaveProperty('node.id', 2);
+
+    nextRes({
+      operation: deferredOp,
+      data: {
+        __typename: 'Query',
+        deferred: {
+          __typename: 'Node',
+          id: 1,
+          name: 'deferred',
+        },
+      },
+      hasNext: true,
+    });
+
+    expect(deferredData).toHaveProperty('deferred.id', 1);
+    expect(combinedData).toHaveProperty('deferred.id', 1);
+    expect(combinedData).toHaveProperty('node.id', 2);
+  });
 });
diff --git a/exchanges/graphcache/src/store/data.test.ts b/exchanges/graphcache/src/store/data.test.ts
index 66f15974..6ccd41df 100644
--- a/exchanges/graphcache/src/store/data.test.ts
+++ b/exchanges/graphcache/src/store/data.test.ts
@@ -475,3 +475,84 @@ describe('commutative changes', () => {
     expect([...data.commutativeKeys]).toEqual([1]);
   });
 });
+
+describe('deferred changes', () => {
+  it('keeps a deferred layer around until completion', () => {
+    // initially it's unknown whether a layer is deferred
+    InMemoryData.reserveLayer(data, 1);
+    InMemoryData.reserveLayer(data, 2);
+
+    InMemoryData.reserveLayer(data, 2);
+    InMemoryData.initDataState('write', data, 2);
+    InMemoryData.writeRecord('Query', 'index', 2);
+    InMemoryData.clearDataState();
+
+    // The layer is marked as deferred via re-reserving it
+    InMemoryData.reserveLayer(data, 1, true);
+    InMemoryData.initDataState('write', data, 1);
+    InMemoryData.clearDataState();
+
+    InMemoryData.initDataState('read', data, null);
+    expect(InMemoryData.readRecord('Query', 'index')).toBe(2);
+
+    // The layers must not be squashed
+    expect(data.optimisticOrder).toEqual([2, 1]);
+
+    // A future response may then clear the layer
+    InMemoryData.reserveLayer(data, 1, false);
+    InMemoryData.initDataState('write', data, 1);
+    InMemoryData.writeRecord('Query', 'index', 1);
+    InMemoryData.clearDataState();
+
+    // The layers must then be squashed
+    expect(data.optimisticOrder).toEqual([]);
+  });
+
+  it('keeps a deferred layer around even if it is the lowest', () => {
+    // initially it's unknown whether a layer is deferred
+    InMemoryData.reserveLayer(data, 1);
+    InMemoryData.reserveLayer(data, 2);
+
+    InMemoryData.reserveLayer(data, 2, true);
+    InMemoryData.initDataState('write', data, 2);
+    InMemoryData.writeRecord('Query', 'index', 2);
+    InMemoryData.clearDataState();
+
+    InMemoryData.initDataState('read', data, null);
+    expect(InMemoryData.readRecord('Query', 'index')).toBe(2);
+
+    // The layers must not be squashed
+    expect(data.optimisticOrder).toEqual([2, 1]);
+
+    // A future response may not clear the layer
+    InMemoryData.reserveLayer(data, 1);
+    InMemoryData.initDataState('write', data, 1);
+    InMemoryData.writeRecord('Query', 'index', 1);
+    InMemoryData.clearDataState();
+
+    // The layers must then be squashed
+    expect(data.optimisticOrder).toEqual([2]);
+    InMemoryData.noopDataState(data, 2, false);
+    expect(data.optimisticOrder).toEqual([]);
+  });
+
+  it('unmarks deferred layers when they receive a noop write', () => {
+    // initially it's unknown whether a layer is deferred
+    InMemoryData.reserveLayer(data, 1);
+    InMemoryData.reserveLayer(data, 2);
+
+    InMemoryData.reserveLayer(data, 2);
+    InMemoryData.initDataState('write', data, 2);
+    InMemoryData.writeRecord('Query', 'index', 2);
+    InMemoryData.clearDataState();
+
+    // The layer is marked as deferred via re-reserving it
+    InMemoryData.reserveLayer(data, 1, true);
+    InMemoryData.initDataState('write', data, 1);
+    InMemoryData.clearDataState();
+
+    // The layer is then receiving a noop write
+    InMemoryData.noopDataState(data, 1, false);
+    expect(data.optimisticOrder).toEqual([]);
+  });
+});
diff --git a/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap b/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
index 032bfc68..cff56c16 100644
--- a/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
+++ b/exchanges/multipart-fetch/src/__snapshots__/multipartFetchExchange.test.ts.snap
@@ -339,6 +339,7 @@ Object {
   },
   "error": undefined,
   "extensions": undefined,
+  "hasNext": false,
   "operation": Object {
     "context": Object {
       "fetchOptions": [MockFunction] {
@@ -513,6 +514,7 @@ Object {
   },
   "error": undefined,
   "extensions": undefined,
+  "hasNext": false,
   "operation": Object {
     "context": Object {
       "fetchOptions": [MockFunction] {
@@ -663,6 +665,7 @@ Object {
   },
   "error": undefined,
   "extensions": undefined,
+  "hasNext": false,
   "operation": Object {
     "context": Object {
       "fetchOptions": [MockFunction] {
diff --git a/exchanges/persisted-fetch/src/persistedFetchExchange.test.ts b/exchanges/persisted-fetch/src/persistedFetchExchange.test.ts
index e973996a..2049cc78 100644
--- a/exchanges/persisted-fetch/src/persistedFetchExchange.test.ts
+++ b/exchanges/persisted-fetch/src/persistedFetchExchange.test.ts
@@ -2,20 +2,12 @@
  * @jest-environment node
  */
 
-import {
-  empty,
-  fromValue,
-  fromArray,
-  pipe,
-  Source,
-  subscribe,
-  toPromise,
-} from 'wonka';
+import { empty, fromValue, fromArray, pipe, Source, toPromise } from 'wonka';
 
 import { DocumentNode, print } from 'graphql';
 import { Client, OperationResult } from '@urql/core';
 
-import { queryOperation, mutationOperation } from './test-utils';
+import { queryOperation } from './test-utils';
 import { hash } from './sha256';
 import { persistedFetchExchange } from './persistedFetchExchange';
 
@@ -43,7 +35,7 @@ it('accepts successful persisted query responses', async () => {
   };
 
   fetch.mockResolvedValueOnce({
-    json: () => expected,
+    json: () => Promise.resolve(expected),
   });
 
   const actual = await pipe(
@@ -69,8 +61,12 @@ it('supports cache-miss persisted query errors', async () => {
   };
 
   fetch
-    .mockResolvedValueOnce({ json: () => expectedMiss })
-    .mockResolvedValueOnce({ json: () => expectedRetry });
+    .mockResolvedValueOnce({
+      json: () => Promise.resolve(expectedMiss),
+    })
+    .mockResolvedValueOnce({
+      json: () => Promise.resolve(expectedRetry),
+    });
 
   const actual = await pipe(
     fromValue(queryOperation),
@@ -96,8 +92,12 @@ it('supports GET exclusively for persisted queries', async () => {
   };
 
   fetch
-    .mockResolvedValueOnce({ json: () => expectedMiss })
-    .mockResolvedValueOnce({ json: () => expectedRetry });
+    .mockResolvedValueOnce({
+      json: () => Promise.resolve(expectedMiss),
+    })
+    .mockResolvedValueOnce({
+      json: () => Promise.resolve(expectedRetry),
+    });
 
   const actual = await pipe(
     fromValue(queryOperation),
@@ -125,9 +125,15 @@ it('supports unsupported persisted query errors', async () => {
   };
 
   fetch
-    .mockResolvedValueOnce({ json: () => expectedMiss })
-    .mockResolvedValueOnce({ json: () => expectedRetry })
-    .mockResolvedValueOnce({ json: () => expectedRetry });
+    .mockResolvedValueOnce({
+      json: () => Promise.resolve(expectedMiss),
+    })
+    .mockResolvedValueOnce({
+      json: () => Promise.resolve(expectedRetry),
+    })
+    .mockResolvedValueOnce({
+      json: () => Promise.resolve(expectedRetry),
+    });
 
   const actual = await pipe(
     fromArray([queryOperation, queryOperation]),
@@ -141,22 +147,6 @@ it('supports unsupported persisted query errors', async () => {
   expect(actual.data).not.toBeUndefined();
 });
 
-it('ignores mutations', async () => {
-  const result = jest.fn();
-  fetch.mockResolvedValueOnce(undefined);
-
-  pipe(
-    fromValue(mutationOperation),
-    persistedFetchExchange()(exchangeArgs),
-    subscribe(result)
-  );
-
-  await Promise.resolve();
-
-  expect(result).toHaveBeenCalledTimes(0);
-  expect(fetch).toHaveBeenCalledTimes(0);
-});
-
 it('correctly generates an SHA256 hash', async () => {
   const expected = {
     data: {
@@ -164,8 +154,8 @@ it('correctly generates an SHA256 hash', async () => {
     },
   };
 
-  fetch.mockResolvedValueOnce({
-    json: () => expected,
+  fetch.mockResolvedValue({
+    json: () => Promise.resolve(expected),
   });
 
   const queryHash = await hash(print(queryOperation.query));
@@ -180,8 +170,8 @@ it('correctly generates an SHA256 hash', async () => {
 
   const body = JSON.parse(fetch.mock.calls[0][1].body);
 
-  expect(queryHash).toMatchInlineSnapshot(
-    `"bfa84414672fe625d36f2d2a52e1d3c1e71c5a01e79599c320db7656d6f014d4"`
+  expect(queryHash).toBe(
+    'bfa84414672fe625d36f2d2a52e1d3c1e71c5a01e79599c320db7656d6f014d4'
   );
 
   expect(body).toMatchObject({
@@ -202,7 +192,7 @@ it('supports a custom hash function', async () => {
   };
 
   fetch.mockResolvedValueOnce({
-    json: () => expected,
+    json: () => Promise.resolve(expected),
   });
 
   const hashFn = jest.fn((_input: string, _doc: DocumentNode) => {
@@ -246,7 +236,7 @@ it('falls back to a non-persisted query if the hash is falsy', async () => {
   };
 
   fetch.mockResolvedValueOnce({
-    json: () => expected,
+    json: () => Promise.resolve(expected),
   });
 
   const hashFn = jest.fn(() => Promise.resolve(''));
diff --git a/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap b/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
index 69d4494f..897dca04 100644
--- a/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
+++ b/packages/core/src/exchanges/__snapshots__/fetch.test.ts.snap
@@ -339,6 +339,7 @@ Object {
   },
   "error": undefined,
   "extensions": undefined,
+  "hasNext": false,
   "operation": Object {
     "context": Object {
       "fetchOptions": [MockFunction] {
diff --git a/packages/core/src/exchanges/__snapshots__/subscription.test.ts.snap b/packages/core/src/exchanges/__snapshots__/subscription.test.ts.snap
index 3961537e..b40072fb 100644
--- a/packages/core/src/exchanges/__snapshots__/subscription.test.ts.snap
+++ b/packages/core/src/exchanges/__snapshots__/subscription.test.ts.snap
@@ -5,6 +5,7 @@ Object {
   "data": Object {},
   "error": undefined,
   "extensions": undefined,
+  "hasNext": false,
   "operation": Object {
     "context": Object {
       "fetchOptions": Object {
diff --git a/packages/core/src/exchanges/ssr.test.ts b/packages/core/src/exchanges/ssr.test.ts
index 6186327a..76b0548a 100644
--- a/packages/core/src/exchanges/ssr.test.ts
+++ b/packages/core/src/exchanges/ssr.test.ts
@@ -19,12 +19,16 @@ const serializedQueryResponse = {
 
 beforeEach(() => {
   input = makeSubject<Operation>();
-  output = jest.fn();
+  output = jest.fn(operation => ({ operation }));
   forward = ops$ => pipe(ops$, map(output));
   client = { suspense: true } as any;
   exchangeInput = { forward, client };
 });
 
+afterEach(() => {
+  output.mockClear();
+});
+
 it('caches query results correctly', () => {
   output.mockReturnValueOnce(queryResponse);
 
@@ -160,6 +164,32 @@ it('resolves cached query results correctly', () => {
   expect(onPush).toHaveBeenCalledWith(queryResponse);
 });
 
+it('resolves deferred, cached query results correctly', () => {
+  const onPush = jest.fn();
+
+  const ssr = ssrExchange({
+    isClient: true,
+    initialState: {
+      [queryOperation.key]: {
+        hasNext: true,
+        ...(serializedQueryResponse as any),
+      },
+    },
+  });
+
+  const { source: ops$, next } = input;
+  const exchange = ssr(exchangeInput)(ops$);
+
+  pipe(exchange, forEach(onPush));
+  next(queryOperation);
+
+  const data = ssr.extractData();
+  expect(Object.keys(data).length).toBe(1);
+  expect(output).toHaveBeenCalledTimes(1);
+  expect(onPush).toHaveBeenCalledTimes(2);
+  expect(onPush.mock.calls[1][0]).toEqual({ hasNext: true, ...queryResponse });
+});
+
 it('deletes cached results in non-suspense environments', async () => {
   client.suspense = false;
   const onPush = jest.fn();
diff --git a/packages/core/src/internal/__snapshots__/fetchSource.test.ts.snap b/packages/core/src/internal/__snapshots__/fetchSource.test.ts.snap
index d69ab87a..17f56d6a 100644
--- a/packages/core/src/internal/__snapshots__/fetchSource.test.ts.snap
+++ b/packages/core/src/internal/__snapshots__/fetchSource.test.ts.snap
@@ -489,6 +489,7 @@ Object {
   },
   "error": undefined,
   "extensions": undefined,
+  "hasNext": false,
   "operation": Object {
     "context": Object {
       "fetchOptions": Object {
@@ -653,6 +654,7 @@ Object {
   },
   "error": undefined,
   "extensions": undefined,
+  "hasNext": false,
   "operation": Object {
     "context": Object {
       "fetch": [MockFunction] {
diff --git a/packages/core/src/internal/fetchSource.test.ts b/packages/core/src/internal/fetchSource.test.ts
index c8203108..a8e97fd7 100644
--- a/packages/core/src/internal/fetchSource.test.ts
+++ b/packages/core/src/internal/fetchSource.test.ts
@@ -1,7 +1,10 @@
-import { pipe, subscribe, toPromise } from 'wonka';
+import { pipe, scan, subscribe, toPromise } from 'wonka';
 
-import { queryOperation } from '../test-utils';
+import { queryOperation, context } from '../test-utils';
 import { makeFetchSource } from './fetchSource';
+import { gql } from '../gql';
+import { OperationResult, Operation } from '../types';
+import { makeOperation } from '../utils';
 
 const fetch = (global as any).fetch as jest.Mock;
 const abort = jest.fn();
@@ -16,7 +19,7 @@ beforeAll(() => {
   };
 });
 
-afterEach(() => {
+beforeEach(() => {
   fetch.mockClear();
   abort.mockClear();
 });
@@ -125,7 +128,7 @@ describe('on error', () => {
 
 describe('on teardown', () => {
   it('does not start the outgoing request on immediate teardowns', () => {
-    fetch.mockRejectedValueOnce(abortError);
+    fetch.mockRejectedValue(abortError);
 
     const { unsubscribe } = pipe(
       makeFetchSource(queryOperation, 'https://test.com/graphql', {}),
@@ -138,7 +141,7 @@ describe('on teardown', () => {
   });
 
   it('aborts the outgoing request', async () => {
-    fetch.mockRejectedValueOnce(abortError);
+    fetch.mockRejectedValue(abortError);
 
     const { unsubscribe } = pipe(
       makeFetchSource(queryOperation, 'https://test.com/graphql', {}),
@@ -152,3 +155,384 @@ describe('on teardown', () => {
     expect(abort).toHaveBeenCalledTimes(1);
   });
 });
+
+describe('on multipart/mixed', () => {
+  const wrap = (json: object) =>
+    '\r\n' +
+    'Content-Type: application/json; charset=utf-8\r\n\r\n' +
+    JSON.stringify(json) +
+    '\r\n---';
+
+  it('listens for more responses (stream)', async () => {
+    fetch.mockResolvedValue({
+      status: 200,
+      headers: {
+        get() {
+          return 'multipart/mixed';
+        },
+      },
+      body: {
+        getReader: function () {
+          let cancelled = false;
+          const results = [
+            {
+              done: false,
+              value: Buffer.from('\r\n---'),
+            },
+            {
+              done: false,
+              value: Buffer.from(
+                wrap({
+                  hasNext: true,
+                  data: {
+                    author: {
+                      id: '1',
+                      name: 'Steve',
+                      __typename: 'Author',
+                      todos: [{ id: '1', text: 'stream', __typename: 'Todo' }],
+                    },
+                  },
+                })
+              ),
+            },
+            {
+              done: false,
+              value: Buffer.from(
+                wrap({
+                  path: ['author', 'todos', 1],
+                  data: { id: '2', text: 'defer', __typename: 'Todo' },
+                  hasNext: true,
+                })
+              ),
+            },
+            {
+              done: false,
+              value: Buffer.from(wrap({ hasNext: false }) + '--'),
+            },
+            { done: true },
+          ];
+          let count = 0;
+          return {
+            cancel: function () {
+              cancelled = true;
+            },
+            read: function () {
+              if (cancelled) throw new Error('No');
+
+              return Promise.resolve(results[count++]);
+            },
+          };
+        },
+      },
+    });
+
+    const streamedQueryOperation: Operation = makeOperation(
+      'query',
+      {
+        query: gql`
+          query {
+            author {
+              id
+              name
+              todos @stream {
+                id
+                text
+              }
+            }
+          }
+        `,
+        variables: {},
+        key: 1,
+      },
+      context
+    );
+
+    const chunks: OperationResult[] = await pipe(
+      makeFetchSource(streamedQueryOperation, 'https://test.com/graphql', {}),
+      scan((prev: OperationResult[], item) => [...prev, item], []),
+      toPromise
+    );
+
+    expect(chunks.length).toEqual(3);
+
+    expect(chunks[0].data).toEqual({
+      author: {
+        id: '1',
+        name: 'Steve',
+        __typename: 'Author',
+        todos: [{ id: '1', text: 'stream', __typename: 'Todo' }],
+      },
+    });
+
+    expect(chunks[1].data).toEqual({
+      author: {
+        id: '1',
+        name: 'Steve',
+        __typename: 'Author',
+        todos: [
+          { id: '1', text: 'stream', __typename: 'Todo' },
+          { id: '2', text: 'defer', __typename: 'Todo' },
+        ],
+      },
+    });
+
+    expect(chunks[2].data).toEqual({
+      author: {
+        id: '1',
+        name: 'Steve',
+        __typename: 'Author',
+        todos: [
+          { id: '1', text: 'stream', __typename: 'Todo' },
+          { id: '2', text: 'defer', __typename: 'Todo' },
+        ],
+      },
+    });
+  });
+
+  it('listens for more responses (defer)', async () => {
+    fetch.mockResolvedValue({
+      status: 200,
+      headers: {
+        get() {
+          return 'multipart/mixed';
+        },
+      },
+      body: {
+        getReader: function () {
+          let cancelled = false;
+          const results = [
+            {
+              done: false,
+              value: Buffer.from('\r\n---'),
+            },
+            {
+              done: false,
+              value: Buffer.from(
+                wrap({
+                  hasNext: true,
+                  data: {
+                    author: {
+                      id: '1',
+                      __typename: 'Author',
+                    },
+                  },
+                })
+              ),
+            },
+            {
+              done: false,
+              value: Buffer.from(
+                wrap({
+                  path: ['author'],
+                  data: { name: 'Steve' },
+                  hasNext: true,
+                })
+              ),
+            },
+            {
+              done: false,
+              value: Buffer.from(wrap({ hasNext: false }) + '--'),
+            },
+            { done: true },
+          ];
+          let count = 0;
+          return {
+            cancel: function () {
+              cancelled = true;
+            },
+            read: function () {
+              if (cancelled) throw new Error('No');
+
+              return Promise.resolve(results[count++]);
+            },
+          };
+        },
+      },
+    });
+
+    const AuthorFragment = gql`
+      fragment authorFields on Author {
+        name
+      }
+    `;
+
+    const streamedQueryOperation: Operation = makeOperation(
+      'query',
+      {
+        query: gql`
+          query {
+            author {
+              id
+              ...authorFields @defer
+            }
+          }
+
+          ${AuthorFragment}
+        `,
+        variables: {},
+        key: 1,
+      },
+      context
+    );
+
+    const chunks: OperationResult[] = await pipe(
+      makeFetchSource(streamedQueryOperation, 'https://test.com/graphql', {}),
+      scan((prev: OperationResult[], item) => [...prev, item], []),
+      toPromise
+    );
+
+    expect(chunks.length).toEqual(3);
+
+    expect(chunks[0].data).toEqual({
+      author: {
+        id: '1',
+        __typename: 'Author',
+      },
+    });
+
+    expect(chunks[1].data).toEqual({
+      author: {
+        id: '1',
+        name: 'Steve',
+        __typename: 'Author',
+      },
+    });
+
+    expect(chunks[2].data).toEqual({
+      author: {
+        id: '1',
+        name: 'Steve',
+        __typename: 'Author',
+      },
+    });
+  });
+
+  it('listens for more responses (defer-neted)', async () => {
+    fetch.mockResolvedValue({
+      status: 200,
+      headers: {
+        get() {
+          return 'multipart/mixed';
+        },
+      },
+      body: {
+        getReader: function () {
+          let cancelled = false;
+          const results = [
+            {
+              done: false,
+              value: Buffer.from('\r\n---'),
+            },
+            {
+              done: false,
+              value: Buffer.from(
+                wrap({
+                  hasNext: true,
+                  data: {
+                    author: {
+                      id: '1',
+                      name: 'Steve',
+                      address: {
+                        country: 'UK',
+                        __typename: 'Address',
+                      },
+                      __typename: 'Author',
+                    },
+                  },
+                })
+              ),
+            },
+            {
+              done: false,
+              value: Buffer.from(
+                wrap({
+                  path: ['author', 'address'],
+                  data: { street: 'home' },
+                  hasNext: true,
+                })
+              ),
+            },
+            {
+              done: false,
+              value: Buffer.from(wrap({ hasNext: false }) + '--'),
+            },
+            { done: true },
+          ];
+          let count = 0;
+          return {
+            cancel: function () {
+              cancelled = true;
+            },
+            read: function () {
+              if (cancelled) throw new Error('No');
+
+              return Promise.resolve(results[count++]);
+            },
+          };
+        },
+      },
+    });
+
+    const AddressFragment = gql`
+      fragment addressFields on Address {
+        street
+      }
+    `;
+
+    const streamedQueryOperation: Operation = makeOperation(
+      'query',
+      {
+        query: gql`
+          query {
+            author {
+              id
+              address {
+                id
+                country
+                ...addressFields @defer
+              }
+            }
+          }
+
+          ${AddressFragment}
+        `,
+        variables: {},
+        key: 1,
+      },
+      context
+    );
+
+    const chunks: OperationResult[] = await pipe(
+      makeFetchSource(streamedQueryOperation, 'https://test.com/graphql', {}),
+      scan((prev: OperationResult[], item) => [...prev, item], []),
+      toPromise
+    );
+
+    expect(chunks.length).toEqual(3);
+
+    expect(chunks[0].data).toEqual({
+      author: {
+        id: '1',
+        name: 'Steve',
+        address: {
+          country: 'UK',
+          __typename: 'Address',
+        },
+        __typename: 'Author',
+      },
+    });
+
+    expect(chunks[1].data).toEqual({
+      author: {
+        id: '1',
+        name: 'Steve',
+        address: {
+          country: 'UK',
+          street: 'home',
+          __typename: 'Address',
+        },
+        __typename: 'Author',
+      },
+    });
+  });
+});
diff --git a/packages/core/src/test-utils/samples.ts b/packages/core/src/test-utils/samples.ts
index 048938e3..599c6585 100644
--- a/packages/core/src/test-utils/samples.ts
+++ b/packages/core/src/test-utils/samples.ts
@@ -9,7 +9,7 @@ import {
 } from '../types';
 import { makeOperation } from '../utils';
 
-const context: OperationContext = {
+export const context: OperationContext = {
   fetchOptions: {
     method: 'POST',
   },