dilansri blogs

Graphql defer example

January 22, 2021

Graphql comes with handy capabilities when developing end user facing applications and in this blog post I’m going to talk about @defer directive and demonstrate it with a working example in NodeJS.

If you want to follow along with me checkout my example github repo graphql-defer-example

Both @defer and @stream directives are still under proposal stage as of this writing and with Apollo its available as an experimental feature.

With standard graphql request/reponse model, if you are requesting large amount of data from your graphql query, the client has to wait until all the fields are resolved in graphql server to get a response.

With the @defer directive you can de-prioritize part of your data in your query so that they’ll be available to the client at a later stage.

To achieve this we can use standard htt multipart responses from our graphql server.

In this example, I’ll be using

Refer to graphql-defer-example repo if you want to examine the complete code.

1. Creating an express server with /graphql endpoint

import express from 'express'
const app = express()
const PORT = 8000

app.use(express.json())

app.use('/graphql', async (req, res) => {

  return res.send('ok')
  
})

app.listen(PORT, () => {
  console.log(`⚡️[server]: Server is running at https://localhost:${PORT}`)
})

2. Setup the typed graphql schema with nexus

import { makeSchema, queryType, objectType, list } from 'nexus'
import * as path from 'path'
import { products } from './resolvers'
...

const Query = queryType({
  definition(t) {
    t.field('product', {
      type: Product,
      resolve: () => ({id: 'x', name: 'y', images: []}) // return static since we will focus more on products query
    })
    t.field('products', {
      type: list(Product),
      resolve: products
    })
  },
})

const Image = objectType({
  name: 'Image',
  definition(t) {
    t.string('url')
  }
})

const Product = objectType({
  name: 'Product',
  description: 'Defines a single product',
  definition(t) {
    t.nonNull.string('id')
    t.string('name')
    t.field('images', {
      type: list(Image)
    })
    t.field('relatedProducts', {
      type: list(Product),
    })
  }
})

export const schema = makeSchema({
  types: [Query, Product, Image],
  outputs: {
    schema: path.join(__dirname, '../generated/schema.graphql'),
    typegen: path.join(__dirname, '../generated/typings.ts'),
  },
})

Here we have the products resolver that will be resolved to a static data included in the repository.

3. Mimick the slow resolver so we can experiment @defer directive

...
const Product = objectType({
  name: 'Product',
  description: 'Defines a single product',
  definition(t) {
    t.nonNull.string('id')
    t.string('name')
    t.field('images', {
      type: list(Image)
    })
    t.field('relatedProducts', {
      type: list(Product),
    })
    t.field('comments', {
      type: list('String'),
      resolve: async ({ id }) => new Promise((resolve) => {
        const productComments = comments(id) || null
        setTimeout(() => resolve(productComments), 1000) // settimeout for testing deferring on comments
      })
    })
  }
})
...

Notice the comments field that will be resolved after 1 second when we request it with our products.

4. Add graphql-helix to process the incoming graphql queries

import express from 'express'
import { 
  getGraphQLParameters, 
  processRequest, 
  shouldRenderGraphiQL, 
  renderGraphiQL 
} from 'graphql-helix'
import { schema } from './graphql/schema'
....

app.use('/graphql', async (req, res) => {

  const {
    query,
    variables,
    operationName
  } = getGraphQLParameters(req)

  const result = await processRequest({
    schema,
    query,
    variables,
    operationName,
    request: req,
  })

})

...

The result you’ll get from graphql-helix has 3 types.

  • RESPONSE - when you have a query without @defer, @stream directives
  • PUSH - when you have a graphql subscription
  • MULTIPART_RESPONSE - when your query includes @defer or @stream directive

Since we are interested in MULTIPART_RESPONSE lets process it.

5. Process and send a response with a multipart response

...
if(result.type === 'MULTIPART_RESPONSE') {
    
  req.on('close', () => {
    result.unsubscribe()
  })

  const response = new MultipartResponse(res, 'mixed', 'gcgfr')

  await result.subscribe((res) => {
    response.add(res)
  })

  return response.end()
}
...

Since there are no proper multipart response builder available, for this example I created a basic MultipartResponse class for express.

The important things to notice are that we use express response.writeHead initially and then response.write methods for subsequent responses that we will be sent to client as patches. And finally, response.end at the end to indicate to close the connection.

6. Lets add graphiql to test out our defer example

graphql-helix comes with nice and easy function to mount graphiql based the browser request.

...
app.use('/graphql', async (req, res) => {

  ...

  if(shouldRenderGraphiQL(req)) {
    return res.send(renderGraphiQL({defaultQuery: exampleQuery}))
  }

  ...

})
...

defaultQuery is the one that get populated on your graphiql interface as the default.

7. Try out a query with defer directive in graphiql interface

fragment Comments on Product{
  comments
}

fragment RelatedProducts on Product {
  relatedProducts {
    id
    name
  }
}

query {
  products{
    id
    ...Comments @defer
    ...RelatedProducts @defer
  }
}

Here we define two fragments Comments, RelatedProducts so we can apply @defer directive to them.

8. Lets examine the response we get

--gcgfr
Content-Type: application/json; charset=utf-8
Content-Length: 71

{"data":{"products":[{"id":"1"},{"id":"2"},{"id":"3"}]},"hasNext":true}

--gcgfr
Content-Type: application/json; charset=utf-8
Content-Length: 127

{"data":{"relatedProducts":[{"id":"2","name":"product-2"},{"id":"3","name":"product-3"}]},"path":["products",0],"hasNext":true}

--gcgfr
Content-Type: application/json; charset=utf-8
Content-Length: 97

{"data":{"relatedProducts":[{"id":"1","name":"product-1"}]},"path":["products",1],"hasNext":true}

--gcgfr
Content-Type: application/json; charset=utf-8
Content-Length: 97

{"data":{"relatedProducts":[{"id":"1","name":"product-1"}]},"path":["products",2],"hasNext":true}

--gcgfr
Content-Type: application/json; charset=utf-8
Content-Length: 91

{"data":{"comments":["good review 1","bad review 1"]},"path":["products",0],"hasNext":true}

--gcgfr
Content-Type: application/json; charset=utf-8
Content-Length: 90

{"data":{"comments":["average review 1","review 1"]},"path":["products",1],"hasNext":true}

--gcgfr
Content-Type: application/json; charset=utf-8
Content-Length: 80

{"data":{"comments":["average review 2"]},"path":["products",2],"hasNext":false}

We see 7 parts in our reponse each seperated with the boundary --gcgfr each which arrived as a chunk to the client seperately.

Also in the graphiql interface its visible thats the comments are appearing after few seconds.

Defer Result

Thank you for reading.