Caching the GraphQL Introspection Query in a CDN
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
:
Defining the strategy
The strategy to route the introspection query via a CDN involves the following steps:
- In the client, identify if the GraphQL query is the introspection query
- If it is, then:
- Replace the GraphQL endpoint, pointing to the CDN instead of the server
- Send the request via
GET
instead ofPOST
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!
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:
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.