Skip to content

BREAKING: fix defer de-duplication, and update public defer structs#4223

Open
chad-bekmezian-snap wants to merge 13 commits into
99designs:masterfrom
chad-bekmezian-snap:fix-defer-deduplication
Open

BREAKING: fix defer de-duplication, and update public defer structs#4223
chad-bekmezian-snap wants to merge 13 commits into
99designs:masterfrom
chad-bekmezian-snap:fix-defer-deduplication

Conversation

@chad-bekmezian-snap

@chad-bekmezian-snap chad-bekmezian-snap commented Jun 17, 2026

Copy link
Copy Markdown

This change is a breaking change on insofar as publicly exported types are concerned. Specifically the DeferGroup type has undergone a breaking change. The change to the server isn't a breaking change from a graphql client perspective.

Most of this pr is noise from codegen. The actual changes can be found in the graphql directory, and object.gotpl.
Describe your PR and link to any relevant issues.

Addresses the issues in #4213.

Examples

Example 1

details

Query

query DeferDeduplication {
  system {
    ...JustUserDetails @defer(label: "justUserDetails")
    ...UserAndAccountDetails @defer(label: "userAndAccountDetails")
  }
}

fragment JustUserDetails on System {
  user {
    id
  }
}

fragment UserAndAccountDetails on System {
  user {
    id
    ... @defer(label: "userName") {
      name
    }
  }
  account {
    id
    name
  }
}

Before

---
Content-Type: application/json

{"data":{"system":{"user":null,"account":null}},"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"user":{"id":"userId","name":""},"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
-----

After

---
Content-Type: application/json

{"data":{"system":{"user":null,"account":null}},"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"user":{"id":"userId","name":""}},"label":"justUserDetails","path":["system"],"hasNext":true}],"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
-----

Response Diff

--- before.txt	2026-06-18 12:17:01
+++ b_after.txt	2026-06-18 12:17:07
@@ -5,5 +5,9 @@
 ---
 Content-Type: application/json
 
-{"incremental":[{"data":{"user":{"id":"userId","name":""},"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
+{"incremental":[{"data":{"user":{"id":"userId","name":""}},"label":"justUserDetails","path":["system"],"hasNext":true}],"hasNext":true}
+---
+Content-Type: application/json
+
+{"incremental":[{"data":{"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
 -----

Notes

We now receive an incremental result for each distinct label in our deferred fragments.

Example 2

details

Query

query DeferDeduplication {
  system {
    ...UserAndAccountDetails @defer(label: "userAndAccountDetails")
    ...JustUserDetails @defer(label: "justUserDetails")
  }
}

fragment JustUserDetails on System {
  user {
    id
  }
}

fragment UserAndAccountDetails on System {
  user {
    id
    ... @defer(label: "userName") {
      name
    }
  }
  account {
    id
    name
  }
}

Before

---
Content-Type: application/json

{"data":{"system":{"user":null,"account":null}},"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"user":{"id":"userId","name":""}},"label":"justUserDetails","path":["system"],"hasNext":true}],"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
-----

After

---
Content-Type: application/json

{"data":{"system":{"user":null,"account":null}},"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"user":{"id":"userId","name":""}},"label":"justUserDetails","path":["system"],"hasNext":true}],"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
-----

Response Diff

Notes

No change. With the guarantee that a result will be delivered for every labeled defer fragment, there is no need to start sending duplicative data.

Example 3

details

Query

query DeferDeduplication {
  system {
     ...JustUserDetails @defer(label: "justUserDetails")
    ...UserAndAccountDetails @defer(label: "userAndAccountDetails")
  }
}

fragment JustUserDetails on System {
  ...UserSystemFragment @defer(label: "userSystem")
}

fragment UserSystemFragment on System {
  user {
    id
  }
}

fragment UserAndAccountDetails on System {
  user {
    id
    ... @defer(label: "userName") {
      name
    }
  }
  account {
    id
    name
  }
}

Before

---
Content-Type: application/json

{"data":{"system":{"user":null,"account":null}},"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"user":{"id":"userId","name":""},"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
-----

After

---
Content-Type: application/json

{"data":{"system":{"user":null,"account":null}},"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"user":{"id":"userId","name":""}},"label":"userSystem","path":["system"],"hasNext":true},{"data":{},"label":"justUserDetails","path":["system"],"hasNext":true}],"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
-----

Response Diff

--- before.txt	2026-06-18 12:19:42
+++ b_after.txt	2026-06-18 12:19:51
@@ -5,5 +5,9 @@
 ---
 Content-Type: application/json
 
-{"incremental":[{"data":{"user":{"id":"userId","name":""},"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
+{"incremental":[{"data":{"user":{"id":"userId","name":""}},"label":"userSystem","path":["system"],"hasNext":true},{"data":{},"label":"justUserDetails","path":["system"],"hasNext":true}],"hasNext":true}
+---
+Content-Type: application/json
+
+{"incremental":[{"data":{"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
 -----

Notes

We once again get an incremental result for each deferred fragment. Even in the case of nested fragments/defers.

Example 4

details

Query

query DeferDeduplication {
  system {
    user {
      id
    }
    ...JustUserDetails @defer(label: "userSystem")
    ...UserAndAccountDetails @defer(label: "userAndAccountDetails")
  }
}

fragment JustUserDetails on System {
  ...UserSystemFragment @defer(label: "userSystem")
}

fragment UserSystemFragment on System {
  user {
    id
  }
}

fragment UserAndAccountDetails on System {
  user {
    id
    ... @defer(label: "userName") {
      name
    }
  }
  account {
    id
    name
  }
}

Before

---
Content-Type: application/json

{"data":{"system":{"user":null,"account":null}},"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"user":{"id":"userId","name":""},"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
-----

After

---
Content-Type: application/json

{"data":{"system":{"user":{"id":"userId","name":""},"account":null}},"hasNext":true}
---
Content-Type: application/json

{"incremental":[{"data":{"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
-----

Response Diff

--- before.txt	2026-06-18 12:20:35
+++ b_after.txt	2026-06-18 12:20:46
@@ -1,9 +1,9 @@
 ---
 Content-Type: application/json
 
-{"data":{"system":{"user":null,"account":null}},"hasNext":true}
+{"data":{"system":{"user":{"id":"userId","name":""},"account":null}},"hasNext":true}
 ---
 Content-Type: application/json
 
-{"incremental":[{"data":{"user":{"id":"userId","name":""},"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
+{"incremental":[{"data":{"account":{"id":"accountId","name":"accountName"}},"label":"userAndAccountDetails","path":["system"],"hasNext":false}],"hasNext":false}
 -----

Notes

In the "before", the user's id was requested within a non-deferred context, yet it was not included in the initial graphql payload. The 2022 spec hints at how this should be handled, stating A query with @defer directive will cause the request to potentially return multiple responses, where non-deferred data is delivered in the initial response and data deferred delivered in a subsequent response. @include and @skip take precedence over @defer. Since the user's id is used within a non-deferred context, it follows it should be included in the initial response. This is behavior is further established as the intended implementation in the examples provided in this discussion: graphql/defer-stream-wg#69.

Open question: as you can see in the above, if the entire dataset in a deferred fragment is delivered in an initial response, no incremental result will be sent for that fragment later. In my testing with facebook's Relay client, it seems to handle this behavior correctly. This also seems to be typical from additional reading I've done, but I am open to sending an incremental result in that case as well.

I have:

  • [ √] Added tests covering the bug / feature (see testing)
  • [ √] Updated any relevant documentation (see docs)

@chad-bekmezian-snap chad-bekmezian-snap marked this pull request as ready for review June 18, 2026 23:20
@StevenACoffman

Copy link
Copy Markdown
Collaborator

I'm very excited to improve @defer in gqlgen for Relay support, but I also want to be careful to avoid breaking Apollo Federation which implemented a very early version of the @defer spec. I would be fine with advancing the target @defer spec version to a more recent (or latest!) version, as long as we don't break existing Apollo Federation support.

@StevenACoffman StevenACoffman added the defer-or-stream Related to GraphQL @defer or @stream directives label Jun 19, 2026
@chad-bekmezian-snap

chad-bekmezian-snap commented Jun 19, 2026

Copy link
Copy Markdown
Author

I'm very excited to improve @defer in gqlgen for Relay support, but I also want to be careful to avoid breaking Apollo Federation which implemented a very early version of the @defer spec. I would be fine with advancing the target @defer spec version to a more recent (or latest!) version, as long as we don't break existing Apollo Federation support.

Do you have a specific concern that makes you think this might break apollo federation?

I considered trying to bump us up towards the latest, but it was going to be more involved than I have the bandwidth for. These changes do lend themselves towards making the move to a newer version much easier though!

@StevenACoffman

Copy link
Copy Markdown
Collaborator

My concern comes from this comment:

At the moment, Apollo Server "implements" the 2022-era format when used with a 2022-era prerelease of graphql-js v17. (By "implements" I mostly mean "serializes the objects returned by graphql-js".) We'd like to update that to the newer format.

When we implemented that, we knew it was an early format up for discussion, so we made clients ask for it specifically with accept: multipart/mixed; deferSpec=20220824 (my suggestion that some accept header be required in graphql/defer-stream-wg#48 didn't seem to go anywhere, maybe because the focus here isn't on HTTP/JSON level).

@chad-bekmezian-snap

chad-bekmezian-snap commented Jun 19, 2026

Copy link
Copy Markdown
Author

My concern comes from this comment:

At the moment, Apollo Server "implements" the 2022-era format when used with a 2022-era prerelease of graphql-js v17. (By "implements" I mostly mean "serializes the objects returned by graphql-js".) We'd like to update that to the newer format.
When we implemented that, we knew it was an early format up for discussion, so we made clients ask for it specifically with accept: multipart/mixed; deferSpec=20220824 (my suggestion that some accept header be required in graphql/defer-stream-wg#48 didn't seem to go anywhere, maybe because the focus here isn't on HTTP/JSON level).

I'm sorry, I'm still not totally following. This pr is still the 20220824 spec. It just involves bug fixes within the implementation of that spec. What am I missing here?

I have compared this implementation to the apollo server implementation for 20220824. There are a couple of differences in the responses still, one being that the same data IS sent multiple times in the apollo implementation. However, that is a difference that existed prior to this change. In every way, this PR brings gqlgen much closer to having identical output to the Apollo Server implementation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like you accidentally checked this binary in

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

defer-or-stream Related to GraphQL @defer or @stream directives

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants