Summary of the latest "no-overlapping-branches" proposal by @benjie & @robrichard
- No overlapping branching.
- No duplicate delivery of any fields
- Reduces the risk of response amplification
- No complex WeakMap/similar caching shenanigans.
- No query rewriting.
- No look-ahead.
- Consistent delivery of fragments.
- Each deferred fragment can be delivered independently
- Labels are entirely optional - only exist for providing information to the client in case it wants to correlate pendings with the original directives.
- Labels do not affect the grouping of defers.
- Deferred fragment data will not be delivered if:
- A null bubbled above the highest field in the deferred fragment
- A null bubbles to a field that is shared with another deferred fragment
The incremental
array is the actual data to be applied to the response, while the pending
and completed
arrays return "metadata" about the execution. They are used to inform clients that defers are being executed and when all fields for that defer have been delivered.
Example A
Overlapping fields from initial payload are not sent in subsequent payloads. Fragment consistency is preserved. Even if "MyFragment" is ready earlier, it is not sent until "j" is also ready.
query ExampleA {
f2 { a b c { d e f { h i } } }
... @defer {
MyFragment: __typename
f2 { a b c { d e f { h j } } }
}
}
Example Result:
[
{
"data": {
"f2": {
"a": "a",
"b": "b",
"c": {
"d": "d",
"e": "e",
"f": { "h": "h", "i": "i" }
}
}
},
// `pending`'s `path` field is always the location of `@defer` directive.
"pending": [{ "path": [], "id": "0" }],
"hasNext": true
},
{
"incremental": [
{ "id": "0", "data": { "MyFragment": "Query" } }
// `subPath` in `incremental` object is a sub-path of the `path` sent in the `pending` object.
{ "id": "0", "subPath": ["f2", "c", "f"], "data": { "j": "j" } }
],
"completed": [{ "id": "0" }],
"hasNext": false
}
]
Example A2
Overlapping fields from parent defers are also not duplicated
query Example A2 {
f2 { a b c { d e f { h i } } }
... @defer(label: "D1") {
f2 { a b c { d e f {
h i j k
...@defer(label: "D2") {
h i j k l m
}
} } }
}
}
Example Result:
[
{
"data": {"f2": {"a": "A", "b": "B", "c": {
"d": "D", "e": "E", "f": {
"h": "H", "i": "I"
}
}}},
"pending": [{"id": "0", "path": [], "label": "D1"}],
"hasNext": true
},
{
"incremental": [
{"id": "0", "subPath": ["f2", "c", "f"] "data": {"j": "J", "k": "K"}}
],
"pending": [{"id": "1", "path": ["f2", "c", "f"], "label": "D2"}],
"completed": [
{"id": "0"}
],
"hasNext": true
},
{
"incremental": [
{"id": "1", "data": {"l": "L", "m": "M"}}
],
"completed": [
{"id": "1"}
],
"hasNext": false
}
]
Example B
Overlapping fields e & f are sent with whichever fragment completes first, and not sent in subsequent payloads.
{
a {
b {
c {
d
}
... @defer(label: "Red") {
e {
f
}
potentiallySlowFieldA
}
}
}
... @defer(label: "Blue") {
a {
b {
e {
f
}
}
}
g {
h
}
potentiallySlowFieldB
}
}
Example Result when potentiallySlowFieldA
completes first:
[
{
"data": {
"a": { "b": { "c": { "d": "d" } } }
},
"pending": [
{ "path": [], "id": "0", "label": "Blue" },
{ "path": ["a", "b"], "id": "1", "label": "Red" }
],
"hasNext": true
},
{
"incremental": [
{ "id": "1", "data": { "potentiallySlowFieldA": "potentiallySlowFieldA" } },
// e is returned in a separate incremental data because there is overlap with other defer paths
{ "id": "1", "data": { "e": { "f": "f" } } }
],
"completed": [{ "id": "1" }]
"hasNext": true
},
{
"incremental": [
// `g` & `potentiallySlowFieldB` are returned in the same incremental data because they both belong to the same set of defers
{ "id": "0", "data": { "g": { "h": "h" }, "potentiallySlowFieldB": "potentiallySlowFieldB" } }
],
"completed": [{ "id": "0" }]
"hasNext": false
}
]
Example Result when potentiallySlowFieldB
completes first:
[
{
"data": {
"a": { "b": { "c": { "d": "d" } } }
},
"pending": [
{ "path": [], "id": "0", "label": "Blue" },
{ "path": ["a", "b"], "id": "1", "label": "Red" }
],
"hasNext": true
},
{
"incremental": [
{ "id": "0", "data": { "g": { "h": "h" }, "potentiallySlowFieldB": "potentiallySlowFieldB" } },
// e is returned in a separate incremental data because there is overlap with other defer paths
// id with longer path is preferred
{ "id": "1", "data": { "e": { "f": "f" } } }
],
"completed": [{ "id": "0" }]
"hasNext": true
},
{
"incremental": [
{ "id": "1", "data": { "potentiallySlowFieldA": "potentiallySlowFieldA" } }
],
"completed": [{ "id": "1" }]
"hasNext": false
}
]
Example D
Non-overlapping fields in child selections of overlapping fields are sent in separate incremental objects.
query ExampleD {
me {
... @defer {
list {
item {
id
}
}
}
...@defer {
me {
list {
item {
value
}
}
}
}
}
Example Result
[
{
"data": { "me": {} },
"pending": [
{ "path": [], "id":" 0" },
{ "path": ["me"], "id": "1" }
],
"hasNext": true
},
{
"incremental": [
{
"id": "1",
"data": { "list": [{ "item": {} }, { "item": {} }, { "item": {} }] }
},
// in this case "id" resolves before value
{ "id": "1", "subPath": ["list", "item", 0], "data": { "id": "1" } },
{ "id": "1", "subPath": ["list", "item", 1], "data": { "id": 2 } },
{ "id": "1", "subPath": ["list", "item", 2], "data": { "id": 3 } }
],
"completed": [{ "id": "1" }],
"hasNext": true
},
{
"incremental": [
{ "id": "0", "subPath": ["me", "list", "item", 0], "data": { "value": "Foo" } },
{ "id": "0", "subPath": ["me", "list", "item", 1], "data": { "value": "Bar" } },
{ "id": "0", "subPath": ["me", "list", "item", 2], "data": { "value": "Baz" } }
],
"completed": [{ "id":" 0" }],
"hasNext": false
}
]
Example F
Defer fragments with no fields are skipped entirely
query ExampleF {
me {
...@defer(label: "A") {
...@defer(label: "B") {
a b
}
}
}
}
Example Result
[
{
"data": {
"me": {}
},
"pending": [
{"id": "0", "path": ["me"], "label": "B"}
],
"hasNext": true
},
{
"incremental": [
{"id":0 , "data": {"a": "A", "b": "B"}}
],
"completed": [
{"id": "0"}
],
"hasNext": false
}
]
Example G
Deferred fragments at the same path are delivered independently
query ExampleG {
me {
...Projects
...Billing @defer(label: "Billing")
}
}
fragment Projects on User {
id
avatarUrl
projects {
name
}
}
fragment Billing on User {
tier
renewalDate
latestInvoiceTotal
...PreviousInvoices @defer(label:"Prev")
}
fragment PreviousInvoices on User {
previousInvoices { name }
}
Example Result
[
{
"data": {
"me": {
"id": 1,
"avatarUrl": "http://…",
"projects": [{ "name": "My Project" }]
}
},
"pending": [
{ "id": 0, "path": ["me"], "label": "Billing" },
{ "id": 1, "path": ["me"], "label": "Prev" }]
],
"hasNext": true
},
{
"incremental": [
{
"id": 0,
"data": {
"tier": "BRONZE",
"renewalDate": "2023-03-20",
"latestInvoiceTotal": "$12.34"
}
}
],
"completed": [{ "id": 0 }],
"hasNext": true
},
{
"incremental": [
{
"id": 1,
"data": { "previousInvoices": [{ "name": "My Invoice" }] }
}
],
"completed": [{ "id": 1 }],
"hasNext": false
}
]
Example H
If a field in a subsequent defer nulls a previously sent field due to null bubbling, the entire fragment will not be delivered. Clients should treat this fragment similar to a fragment that is @skip(if: true)
.
Assume baz
resolves before qux
and qux is non-nullable and returns null.
query ExampleH {
... @defer(label: "A") {
me {
foo {
bar {
baz
}
}
}
}
me {
... @defer(label: "B") {
anotherField
foo {
bar {
qux
}
}
}
}
}
Example Result
[
{
"data": {
"me": {}
},
"pending": [
{"id": "0", "path": [], "label": "A"},
{"id": "1", "path": ["me"], "label": "B"}
],
"hasNext": true
},
{
"incremental": [
{
"id": "0",
"subPath": ["me"],
"data": {
"foo": {
"bar": {
"baz": "BAZ"
}
}
}
}
],
"completed": [
{"id": "0"}
],
"hasNext": true
},
{
// No "incremental" objects are sent. "completed" object contains the
// errors that triggered the conflict due to null bubbling.
"completed": [
{
"id": "1",
"errors": [
{
"message": "Cannot return null for non-nullable field Bar.qux.",
"locations": [...],
"path": ["foo", "bar", "qux"]
}
]
}
],
"hasNext": false
}
]
Example I
query ExampleI {
person(id: "1") {
...HomeWorldFragment @defer(label: "homeWorldDefer")
name
films @stream(initialCount: 2, label: "filmsStream") {
title
}
}
}
fragment HomeWorldFragment on Person {
homeworld {
name
}
}
[
{
"data": {
"person": {
"name": "Luke Skywalker",
"films": [
{ "title": "A New Hope" },
{ "title": "The Empire Strikes Back" }
]
}
},
"pending": [
{"id": "0", "path": ["person"], "label": "homeWorldDefer"},
// `pending` path is location of stream directive
// one `pending` object is sent for the entire stream
{"id": "1", "path": ["person", "films"], "label": "filmsStream"}
],
"hasNext": true
},
{
"incremental": [
// No subpath or index, items must be returned in order.
// Multiple items can be returned in array
{ "id": "1", "items": [{"title": "Return of the Jedi"}] },
],
"hasNext": true
},
{
// `completed` object may be sent after final list item, in cases where the server cannot know the list has ended synchronously
"completed": [{"id": "1"}],
"hasNext": true
}.
{
"incremental": [
{"id": "0", "data": {"homeworld": {"name": "Tatooine"}}},
],
"completed": [{"id": "0"}],
"hasNext": false
}
]
Example J
If after some list fields are streamed:
- either the underlying datasource errors
- or a null bubbles up to the list field
A "completed" object is sent for the stream with the errors and no more streamed results will be sent
query ExampleJ {
person(id: "1") {
films @stream(initialCount: 1, label: "filmsStream") {
title
}
}
}
[
{
"data": {
"person": {
"films": [{ "title": "A New Hope" }]
}
},
"pending": [
{"id": "1", "path": ["person", "films"], "label": "filmsStream"}
],
"hasNext": true
},
{
"incremental": [
// one streamed list item is delivered successfully
{ "id": "1", "items": [{"title": "The Empire Strikes Back"}] }
],
"hasNext": true
},
{
// An error is encountered while loading the next list item
"completed": [
{
"id": "1",
"errors": [
{
"message": "Cannot return null for non-nullable field Person.films.",
"locations": [...],
"path": ["person", "filmns"]
}
]
}
],
"hasNext": false
}
]
[A] | [B] | [C] | [D] | [E] | [F] | [G] | [H] | [I] | [J] | [K] | [L] | [M] | [N] | [O] | [P] | [Q] |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
✅️ | ✅️ | ✅️ | ✅️ | ✅ | ✅️ | ✅️ | ✅️ | ✅️ | ✅ | ✅️ | ✅️ | ✅ | ✅ | ❔ | 🚫 | 🚫 |
It's possible for a single incremental object result to "complete" more than one deferred fragment. In this example assume baz
takes longer to resolve than the other fields.
{
me {
... @defer(label: "A") {
foo
bar
}
... @defer(label: "B") {
bar
baz
}
... @defer(label: "C") {
foo
baz
}
}
}
Example Response
{
data: { me: {} },
pending: [
{id: 0, label: "A", path: ["me"]},
{id: 1, label: "B", path: ["me"]},
{id: 2, label: "C", path: ["me"]}
]
}
{
incremental: [
{id: 0, data: {foo: "foo"}},
{id: 0, data: {bar: "bar"}}
],
completed: [
{id: 0}
]
}
{
incremental: [
{id: 1, data: {baz: "baz"}}
],
completed: [
{id: 1},
{id: 2}
]
}
No, because then it would introduce confusion on which id is the prefix of the subpath. This single id does not signify that this is the only fragment being delivered as multiple fragments can be delivered together. We will prefer the id
with the longest path
in pending
to minimize the size of the subpath.
Hey Rob, thanks for this detailed write up and so many interesting examples!!
Regarding example H, I'm not sure why "anotherField" need to suffer the casualty and not be delivered? Maybe you can specify the schema's nullability?