Summary: This is a discussion of GraphQL query language idioms as they relate to application performance. GraphQL allows us to encapsulate multiple requests into a single operation. This prevents the cost of multiple round trips from our app clients to our API servers. When we take advantage of GraphQL's contract decoupling, we can send the minimal amount of data necessary across services and systems while also minimizing the number of required network requests.

Client/Server Contract Decoupling

Coupling is a computing metric describing how closely connected two routines or modules are. It is a measure of quality. In a distributed architecture where systems are independently deployable, coupling between systems can still be high by some measures, which is undesirable. 

For example, with a tightly coupled HTTP API, when we modify the API producer (server-side), it must be reflected on the consumer (client-side). Vice-versa, if the needs of the client-side application change, we must modify the API service. This dependency is not practical. Often with a tightly coupled API, a single routine on the server is connected to a single network request. To perform a particular function on the client-side, — this can be as prosaic as hydrating a screen with content — we must send a latency multiplying chain of these requests. 

Often an API service and presentation layer are not developed collaboratively by a single team (in a tight BFF pattern), and the interfaces don't map perfectly. The business requirements evolved agilely, perhaps. In RESTful implementations, stamp coupling is a root cause for consuming significant amounts of unnecessary bandwidth. Stamp coupling occurs when a composite data structure is shared between two modules. Some fields in the data structure may not be used, but all fields are unconditionally provided.

Through innovative design, the authors of the GraphQL specification solved the problem of "how to" achieve a loosely coupled HTTP API with a clear and well-understood contract: the schema is a data graph, which serves as both the scope contract and the source of structure for the API. Contract decoupling makes GraphQL an attractive data API for our distributed applications when we understand how to harness its design fully.

Reduce Bandwidth with Field Selectors

Many GraphQL educational materials focus heavily on the "ask for what you need, no more, no less" mantra, so we will not spend too much time on this topic. The emphasis here is optimizing response size by economizing field selectors in the contract. Reducing the size of the response payload via field selector inputs is not novel to GraphQL (although GraphQL's solution is arguably the most elegant). 

Some well-designed RESTful API implementations frequently use runtime expressions — query string parameters on the URL — for dynamic, cacheable result filtering (plus sorting and searching) to mitigate stamp coupling. We know bandwidth is not infinite; therefore, ensuring the minimal amount of data is passed between systems in a distributed architecture is of great value. Yet, GraphQL packs more novel opportunities for performance optimization in its client/server contract decoupling, which are worth highlighting.

Reduce Network Latency by Batching Read Requests

The fundamental truth is that latency in a distributed architecture is never zero, which is why the fewer round-trip network requests we can make, the better our applications will perform. One tried-and-true method for sending fewer network requests is to build a client-side caching layer that retrieves data from a local store. However, in the absence of a primed cache or when the data needs to be near-real-time, requests will still need to hit the server. 

GraphQL's core abstraction, the graph, allows us to encapsulate multiple query operations from the root query into a single operation — and therefore a single request  — avoiding the cost of multiple round trips from our app clients to our API servers. The novel capability to aggregate the read units of work we want the server to perform is inherent to the graph structure of this API specification (specifically, the directed graph structure of the document sent in the request body).  This is an innovation of the GraphQL specification that is not present in earlier API spe cifications or styles.

Let's consider the following example scenario. Our media and entertainment app has a search screen where we want to present all results from a user inquiry. Results must include Series and Episode entities with a match in the title or keywords fields. Regarding result ordering: a match on the entity title field should be considered more relevant than a match on the entity keywords field. Our API already supports a search operation per entity (Series/Episode), which generically accepts a filter argument, allowing us to reduce our search results by title or keywords. 

There is a known limit to this service: when a single query operation includes multiple filter statements, the API will not guarantee the result order, e.g., when title and keywords fields are in the filter statement, title matches may not precede keyword matches. This limit implies that we must perform two different searches per entity. 

If our API were a RESTful or an RPC style implementation, we would have a couple of unattractive options: send four latency multiplying HTTP requests or wait to change the client-side presentation until the development of a new server-side endpoint for our specific UI/UX requirements has gone live. With GraphQL we don’t face the same dilemma because we can encapsulate all four searches into a single lower-latency HTTP request (while iterating quickly on our feature backlog). The following query document is an example of batching our searches into a single operation.

query searchScreenResults {
  searchSeriesByTitle: searchSeries(
    filter: { title: { matchPhrase: "american" } }
  ) {
    series {
      title
      seasons {
        episodes {
          title
        }
        number
      }
    }
  }
  searchSeriesByKeywords: searchSeries(
    filter: {
      or: [
        { keywords: { matchPhrase: "american" } }
        { keywords: { wildcard: "*american*" } }
      ]
    }
  ) {
    series {
      title
      seasons {
        episodes {
          title
        }
        number
      }
      keywords
    }
  }
  searchEpisodesByTitle: searchEpisodes(
    filter: { title: { matchPhrase: "american" } }
  ) {
    episodes {
      title
    }
  }
  searchEpisodesByKeywords: searchEpisodes(
    filter: {
      or: [
        { keywords: { matchPhrase: "american" } }
        { keywords: { wildcard: "*american*" } }
      ]
    }
  ) {
    episodes {
      title
      keywords
    }
  }
}

Parallel Execution from Root Query

In the article The Graph in GraphQL, the author explains in digestible terms, "a GraphQL query is a path in the graph, going from the root type to its subtypes until we reach scalar types with no subfields. As a result, a query is a projection of a certain subset of the GraphQL schema to a tree." The GraphQL specification directs that, in the case of query operations, the server should execute a grouped field set normally (allowing for parallelization). 

The depth‐first‐search order of field groups is maintained through execution, ensuring that fields appear in the response in a stable and predictable order across the parallel resolution of branches from the root. Below is a visualization of batched searches (related to the above query document) executing in parallel from the root query down to the scalar fields. 

visualization of parallelized query execution on the server

Serial Execution from Root Mutation

Let's note that while we can also encapsulate multiple mutation operations from the root mutation into a single operation, the batching technique will be less effective at increasing speed in a write-scenario because server-side execution is not parallelized for mutations. Mutations have side effects. Hence, the GraphQL specification clearly states, "When executing a mutation, the selections in the top most selection set will be executed in serial order, starting with the first appearing field textually."

Front-end Component Architecture

Although reducing multiple requests down to one can deliver performance gains, we must consider how this impacts our client-side component architecture.  This Apollo blog post does a great job of explaining trade-offs and techniques:

"If there was a page with four content blocks on it, rather than having each block fetch its own data, a container could fetch the data and pass it to the components manually. 

This may sound counterintuitive to the patterns that have been established, like colocating queries with the components that use their response, but there are ways around this.

This isn't suggesting to write one large GraphQL query at the container level. Instead, write queries normally, next to the components that use them. When you're ready to optimize a section of an app, convert those queries to fragments and export them from the component file. You can then import these fragments in the container, let the container make the single, large query, and pass the fragment results back to the children. Using container components in this way can even allow you to control loading and error states at the container-level, rather than in each component."

Rather than prematurely automating batching for all requests, which comes with some obvious disadvantages (e.g., batched operations are always as slow as the slowest operation in the batch), the wise approach is to tune performance as needed. Using a well-factored component architecture will enable down-the-road performance tuning.

A link to a working example of this pattern has been included, along with some embedded code samples that follow.

// src/index.js

import { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";

import Films, { fragment as fragmentFilmsComponent } from "./Films";
import People, { fragment as fragmentPeopleComponent } from "./People";

function Container() {
  const [data, setData] = useState(null);
  const [status, setStatus] = useState("");

  useEffect(() => {
    setStatus("loading");
    fetch("https://swapi-graphql.netlify.app/.netlify/functions/index", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        query: `
        query Container {
          ...FilmsComponent
          ...PeopleComponent
        }
        ${fragmentFilmsComponent}
        ${fragmentPeopleComponent}
          `
      })
    })
      .then((res) => res.json())
      .then((payload) => {
        setStatus("success");
        setData(payload);
      })
      .catch((error) => {
        setStatus("error");
      });
  }, []);

  if (status !== "success") {
    return <p>{status}...</p>;
  }

  return (
    <div>
      <h1>SWAPI: Films & People</h1>
      <Films data={data?.data?.allFilms} status={status} />
      <People data={data?.data?.allPeople} status={status} />
    </div>
  );
}

const rootElement = document.getElementById("root");
const root = createRoot(rootElement);

root.render(
  <StrictMode>
    <div style=>
  <Container />
    </div>
  </StrictMode>
);

// src/Films.js

export const fragment = `
  fragment FilmsComponent on Root {
    allFilms {
      films {
        title
        characterConnection {
          characters {
            name
            species {
              name
            }
          }
        }
      }
      totalCount
    }
  }
`;

export default ({ data, status }) => (
  <div>
    {status === "success" && data && (
      <>
        <h2>Films ({data?.totalCount})</h2>
        <ul>
          {data?.films.map((item, index) => (
            <li key={index}>{item?.title}</li>
          ))}
        </ul>
      </>
    )}
  </div>
);  

// src/People.js

export const fragment = `
  fragment PeopleComponent on Root {
    allPeople {
      totalCount
      people {
        name
        species {
          name
        }
      }
    }
  }
`;

export default ({ data, status }) => (
  <div>
    {status === "success" && data && (
      <>
        <h2>People ({data?.totalCount})</h2>
        <ul>
          {data?.people.map((item, index) => (
            <li key={index}>{item?.name}</li>
          ))}
        </ul>
      </>
    )}
  </div>
);

The suggestion to decouple the query document from the component that depends on the query response while keeping the code colocated is an ideal pattern that will enable encapsulating multiple requests into a single operation without heavy refactoring. Consider this technique early on while planning the data-fetching/structure of a React app where the UI relies on a GraphQL API for data.

 


Utilize Contract Decoupling for Performance Benefits

GraphQL allows us organizational and evolutionary flexibility while remaining performant. When we take advantage of GraphQL's contract decoupling, we can send the minimal amount of data necessary across services and systems while minimizing the number of required network requests.  

GraphQL's performance capabilities extend beyond bandwidth reduction to network latency reduction when we loosen the coupling between client-side screens/modules and HTTP requests by batching reads operations into fewer query documents.