GraphQL and Elasticsearch: A Love Letter 💌

Thilo Haas

Januar 2021

Learnings from using Elasticsearch as a GraphQL Backend and how to make the most out of it.

There are several libraries which expose the full Elasticsearch API as a GraphQL API. But like traditional REST APIs with Elasticsearch Backends, we mostly don’t want to expose all the possibilities which the Elasticsearch Query Language offers to the end users.

Let me guide you through how we built a GraphQL API on Elasticsearch while leveraging the advantages of both.

GraphQL and Elasticsearch in the Field

Our GraphQL API on Elasticsearch powers one of the largest websites dedicated to cooking in Switzerland, with thousands of recipes. It delivers relevant search results and personalized teasers in the blink of an eye by leveraging both, the benefits of GraphQL as an aggregated API endpoint and Elasticsearch as a performant full-text search and recommendation engine.

[object Object][object Object][object Object]
Our GraphQL API powers https://migusto.migros.ch as a fast, customizable and user-centered recipe search engine.

A more detailed description of our GraphQL Elasticsearch API in German language can be found on our company page as a one pager: Migros Rezepte: eine schnelle Rezeptsuche für alle.

System Architecture

This is our setup: We have several web frontends which need different data from our Elasticsearch backend and some other 3rd party REST APIs. We will look at an API for recipes which is used to display recipe teasers as search results and the recipe detail page.

[object Object]
GraphQL API with Elasticsearch System Overview

We will focus on our primary goal, which is to provide our web frontends with an easily accessible GraphQL API endpoint for data from Elasticsearch.

Elasticsearch and GraphQL

We could have used one of the existing GraphQL to Elasticsearch libraries — but that would not have served our main goal of a simple end-user friendly API, because:

  • we don’t want to expose all possible Elasticsearch methods with its complex query language
  • we want to determine granularly which documents and fields should be available via the API and hide irrelevant fields(e.g. metadata used for scoring)
  • we only want to expose what’s needed by the end users — and keep as much flexibility as possible for further changes and enhancements without breaking the API
  • we want the ability to include 3rd party resources and APIs in a simple way
  • we want to make the GraphQL API for the end user as simple as possible and hide complex Elasticsearch queries (e.g. the ones needed for full-text search, boosting and personalization) under the hood

Therefore, we decided to build our GraphQL API granularly, based on the needs of our API customers. By explicitly defining the GraphQL API and its schema we are keeping the flexibility to extend and enhance.

Reusing GraphQL, Typescript and Elasticsearch Schema

One key benefit of GraphQL is the well defined schema which makes it easy to consume the API. But this schema must be explicitly defined.

From the data import into Elasticsearch up to the GraphQL endpoint, the data schema is mostly redundant. To minimize error prone copy pasting and code duplication, we used the Code First approach and share the schema. Write once — use everywhere.

Therefore we defined our data schema by using Typescript models. These declarative models are then converted to Elasticsearch and GraphQL schema as needed. We open sourced part of this approach as @smartive/es-model.

Uncover Performance Issues with Loadtests

For meaningful results, we gathered the most frequent queries of our frontends and tested them against the GraphQL API at scale. We used jMeter to set up the load tests and flood.io to parallelize the load onto different machines.

This uncovered performance issues and bottlenecks. That is why we implemented caching for expensive 3rd party requests. We also identified issues with our relational dataset and the way GraphQL works. By denormalizing our datasource we could resolve this issue and even improve the overall performance.

At the end we have a GraphQL API running on two NodeJS nodes and two Elasticsearch nodes, to.

Denormalizing Datasource for Improved Performance

Since we have much more reads than writes to our datasource we could drastically increase the performance by denormalizing our relational datasource in Elasticsearch.

Imagine you want to retrieve the top 10 recipes with their ingredients which match your search term “pie” from the GraphQL API.

javascript
query RecipeSearch {
recipes(search: "pie" limit: 10) {
recipes {
title
image
ingredients { name image }
}
}
}

If the ingredient details like name and image are stored in a separate index/table, then — per default — the GraphQL API will make one additional Elasticsearch Query for every ingredient of every recipe in the result set.

So for one GraphQL API call you would have:

  • 1 Elasticsearch query which retrieves 10 recipes
  • 5 Elasticsearch queries per recipe to retrieve the recipe’s ingredient details (if a recipe has ø 5 ingredients)

This makes a total of 51 Elasticsearch queries per GraphQL query!

We could optimize this by parsing the AST context of the GraphQL Query to bring it down to one Elasticsearch query for all ingredients of the 10 recipes. But this is still too much. If we want to retrieve our result set with one single Elasticsearch query per GraphQL query we need to denormalize our datasource.

[object Object]
Datamodel of a Recipe with Ingredients: Normalized and denormalized

By denormalizing, we include the ingredients detail in every recipe dataset. This way the GraphQL API only needs one single Elasticsearch Query to get all the requested data.

This approach of course only makes sense if you have a high read/write ratio and your datasource changes infrequently. But if that’s the case, then denormalization can drastically improve performance.

Caching Expensive Requests with Redis

Caching GraphQL APIs is much more complex than caching REST endpoints. So to keep things simple, we only applied caching for resources which are not under our control as for example 3rd party APIs.

[object Object]
Redis as an intermediate Cache for expensive Requests

For expensive 3rd Party REST API calls which are not under our control, we’ve chosen to cache data in Redis. The cache is invalidated when data is updated or stale.

Conclusion

GraphQL provides a great framework for organizing and combining your APIs. But it also introduces a lot of complexity when dealing with relational data. Elasticsearch offers professional full-text search and high performance data retrieval. Together they build a world class team. But be aware of your use cases and restrict the APIs to make sure to deliver the best performance.

Build Enterprise-Grade GraphQL Applications

Want to become a GraphQL pro? Follow us and read our whole series on enterprise-grade GraphQL applications.

What are your experiences with GraphQL? We would love to hear about your challenges and how you solved them! Let’s get in touch!