Introspection is GraphQL's mechanism to provide information about the schema, which is retrieved using the same GraphQL language. It is thanks to introspection that clients such as GraphiQL and GraphQL Voyager can help us interact with the GraphQL schema.

These clients always execute the same introspection query to obtain the full data for the schema:

query IntrospectionQuery {
  __schema {
    queryType {
      name
    }
    mutationType {
      name
    }
    subscriptionType {
      name
    }
    types {
      ...FullType
    }
    directives {
      name
      description
      locations
      args {
        ...InputValue
      }
    }
  }
}

fragment FullType on __Type {
  kind
  name
  description
  fields(includeDeprecated: true) {
    name
    description
    args {
      ...InputValue
    }
    type {
      ...TypeRef
    }
    isDeprecated
    deprecationReason
  }
  inputFields {
    ...InputValue
  }
  interfaces {
    ...TypeRef
  }
  enumValues(includeDeprecated: true) {
    name
    description
    isDeprecated
    deprecationReason
  }
  possibleTypes {
    ...TypeRef
  }
}

fragment InputValue on __InputValue {
  name
  description
  type {
    ...TypeRef
  }
  defaultValue
}

fragment TypeRef on __Type {
  kind
  name
  ofType {
    kind
    name
    ofType {
      kind
      name
      ofType {
        kind
        name
        ofType {
          kind
          name
          ofType {
            kind
            name
            ofType {
              kind
              name
              ofType {
                kind
                name
              }
            }
          }
        }
      }
    }
  }
}

The faster this query is resolved, the sooner we can start interacting with GraphQL in the clients. Until then, GraphiQL won't display the autocomplete dropdown when composing the query, and the GraphQL Voyager will be in a blank state.

If the GraphQL schema is static (i.e. it doesn't change based on runtime conditions, such as checking the roles from the logged-in user) then there is a clear performance gain we can achieve: cache the results of fetching the introspection query the first time it's accessed, and retrieve the cached response from then on.

One way to produce this cache is by routing the request through a CDN. The first time the request is invoked, the CDN will forward the request to the origin server, and store its response in the CDN's cache. From then on, the response to the query is retrieved directly from the CDN's cache.

A CDN provides a faster response, because:

  • CDNs are located near the user, decreasing latency time
  • The query does not need be resolved in the server time and again. This is particularly important for introspection because this query could become expensive to calculate, taking a few seconds.

Let's see how to implement it.

Setting-up the CDN

You can use any CDN service you want. In my case, I use AWS Cloudfront via domain nextapi.getpop.org, which fetches the content from the origin server under newapi.getpop.org:

CDN origin

Defining the strategy

The strategy to route the introspection query via a CDN involves the following steps:

  1. In the client, identify if the GraphQL query is the introspection query
  2. If it is, then:
    • Replace the GraphQL endpoint, pointing to the CDN instead of the server
    • Send the request via GET instead of POST

Using GET is required or otherwise the response will not be cached. The implication is that we can't pass the GraphQL query in the request body anymore. One solution is to pass the query via GET param query. But there's a simpler solution: not passing the query at all. After all, the introspection query is fixed, so we can simply store it in the server.

To retrieve the query in the server, we can use a persisted query and reference it via a hash. Another way is to pass a flag in the request to indicate we're executing that query, such as ?useIntrospection=true, check for that flag before initializing the GraphQL server, and then fill-in the introspection query.

Yet another way is to pass the query under param query but prepending it with !, so we can request ?query=!introspectionQuery. Then, the introspection query is stored in the server under name introspectionQuery.

Implementation for GraphiQL

The GraphiQL client connects to the GraphQL endpoint via the fetch method:

const graphQLFetcher = graphQLParams =>
  fetch(
    'https://newapi.getpop.org/api/graphql/',
    {
      method: 'post',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include',
      body: JSON.stringify(graphQLParams)
    }
  )
    .then(response => response.json())
    .catch(() => response.text());

ReactDOM.render(
  React.createElement(
    GraphiQL,
    {
      fetcher: graphQLFetcher,
      query: '{\n  posts {\n    id\n    title\n    excerpt\n    date\n  }\n}\n'
    }
  ),
  document.getElementById('graphiql-client'),
);

We must modify the graphQLFetcher logic to make it dynamic, configuring the GraphQL endpoint and options differently when dealing with the introspection query:

const graphQLFetcher = graphQLParams =>
  fetch(
    getGraphQLEndpointURL(graphQLParams),
    getGraphQLOptions(graphQLParams)
  )
    .then(response => response.json())
    .catch(() => response.text());

Function getGraphQLEndpointURL will provide either the CDN endpoint or the server endpoint:

/**
 * If doing introspection, return the CDN, otherwise the endpoint source
 */
function getGraphQLEndpointURL(graphQLParams) {
  if (doingIntrospectionQuery(graphQLParams)) {
    // Route the GraphQL endpoint through a CDN
    return 'https://nextapi.getpop.org/api/graphql/?query=!introspectionQuery&schemaVersion=0.2.0';
  }
  // The source URL for the GraphQL endpoint
  return 'https://newapi.getpop.org/api/graphql/';
}

The CDN endpoint has an extra param schemaVersion=0.2.0. It is used to "purge" the cached results via code, so that when the GraphQL schema is updated, changing the param to schemaVersion=0.3.0 will have the client immediately access the new schema.

A way to identify the introspection query is to check if its operation name is "IntrospectionQuery":

/**
 * Indicate if the GraphQL query is for introspection
 */
function doingIntrospectionQuery(graphQLParams) {
  return graphQLParams?.operationName == 'IntrospectionQuery';
}

This logic is very simple, but it has a drawback: we can't use operation name IntrospectionQuery with any other query, since it would not produced the intended response:

query IntrospectionQuery {
  __schema {
    queryType {
      name
    }
  }
}

Finally we also update the options, changing the request method from POST to GET, and dropping the body (and also the credentials):

/**
 * If doing introspection, return GET, otherwise POST and the params
 */
function getGraphQLOptions(graphQLParams) {
  if (doingIntrospectionQuery(graphQLParams)) {
    return {
      method: 'get',
      headers: { 'Content-Type': 'application/json' }
    };
  }
  return return {
    method: 'post',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify(graphQLParams)
  };
}

Checking the results

To see the whole set-up working, you can inspect the source code of this documentation page, which prints 13 GraphiQL clients!

Thirteen GraphiQL clients

Visitors to this page may never run the example queries from all 13 GraphiQL clients, yet all clients must be initialized, executing 13 introspection queries.

In normal circumstances, this could place a heavy load on the server. Since routing it thorugh a CDN, whether making 13 or 130 calls doesn't make a difference, all clients become interactive in no time:

Caching GraphQL introspection queries via a CDN

Conclusion

This article demonstrates a simple optimization to interacting with GraphQL, which means that clients are initialized faster, and the load on the server is reduced.