Performance & Caching — GraphQL

Performance & Caching GraphQL performance challenges: semua request POST (no HTTP caching), arbitrary query depth, N+1 queries. Query Complexity & Depth…

GraphQL yang cepat butuh dua kebiasaan: jangan menghitung jawaban yang sama berulang-ulang (cache), dan jangan mau mengerjakan pesanan yang kebesaran (batas biaya). Bayangin meja informasi Toko Buku. Pertanyaan yang sering ditanya, "buku apa saja yang ada?", dijawab dari lembar fotokopi yang ditempel di depan, lengkap dengan jam kedaluwarsanya. Pelanggan tetap mencatat jawaban di buku sakunya sendiri. Dan pesanan "semua buku dari semua penulis dari semua buku..." ditolak dengan sopan sebelum petugasnya berjalan ke gudang.

🧪 Tebakan cepat: Halaman katalog sudah memuat query Katalog { books { id title stock } }, stok Bumi Manusia 0. Admin lalu menjalankan mutation Restock { updateStock(id: 2, stock: 7) { id stock } }. Tanpa query ulang, apakah daftar katalog di layar ikut jadi 7? Jawabannya ada di bagian "Cache di client: dinormalisasi".

Kenapa cache HTTP susah di GraphQL

Cache HTTP (browser, CDN) menyimpan response berdasarkan method dan URL. REST punya GET /books/1, GET /books/2, dan seterusnya: tiap data punya URL sendiri, jadi gampang disimpan. GraphQL mengirim semuanya lewat POST /graphql ke satu endpoint, dengan query di dalam body. Response POST gak disimpan browser dan CDN, dan kalaupun mau, URL-nya sama untuk semua query. Itu sebabnya cache HTTP lebih sulit di GraphQL.

Jalan keluarnya: kirim query lewat GET. Apollo Server menerima GET untuk query saja; mutation lewat GET ditolak dengan status 405 dan pesan GET requests only support query operations, not mutation operations. Masalahnya, teks query ikut masuk URL, jadi URL-nya panjang dan beda sedikit saja sudah jadi URL lain.

Persisted query: kirim hash, bukan teks

Automatic Persisted Queries (APQ): client menghitung SHA-256 dari teks query, yaitu sidik jari 64 karakter heksadesimal, lalu mengirim hash itu saja. Untuk query Katalog { books { id title stock } } hasilnya 25af270b4ef2e6d3a71ad0b5726d0360de88fc53bdeaa56803e91b5ff6d05c1b. Ini percakapan aslinya dengan Apollo Server 5.5.1 (bagian extensions di URL sebenarnya di-encode):

# 1. Kunjungan pertama: hash saja
GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"25af270b...c1b"}}
200 OK
{"errors":[{"message":"PersistedQueryNotFound","extensions":{"code":"PERSISTED_QUERY_NOT_FOUND"}}]}

# 2. Client mengulang dengan teks lengkap dan hash-nya, server menyimpannya
POST /graphql
{"query":"query Katalog { books { id title stock } }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"25af270b...c1b"}}}
200 OK
Cache-Control: max-age=10, public

# 3. Berikutnya, dari pengunjung mana pun: hash saja, langsung data
GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"25af270b...c1b"}}
200 OK
Cache-Control: max-age=10, public
{"data":{"books":[{"id":"1","title":"Laskar Pelangi","stock":12}, ...]}}

Cache di server: cache hint

Server memberi tahu berapa lama sebuah response boleh disimpan. Di Apollo Server, caranya directive @cacheControl di SDL:

enum CacheControlScope { PUBLIC PRIVATE }
directive @cacheControl(maxAge: Int, scope: CacheControlScope, inheritMaxAge: Boolean) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION

type Query {
  books: [Book!]! @cacheControl(maxAge: 60)
  book(id: ID!): Book @cacheControl(maxAge: 60)
  authors: [Author!]!
  viewer: User @cacheControl(maxAge: 30, scope: PRIVATE)
}

type Book @cacheControl(maxAge: 3600) {
  id: ID!
  title: String!
  price: Int!
  stock: Int! @cacheControl(maxAge: 10)
  author: Author!
}

type Author @cacheControl(maxAge: 86400) {
  id: ID!
  name: String!
}

Aturannya, untuk tiap field yang ter-resolve: ambil hint dari type kembaliannya, lalu hint di field-nya sendiri menimpanya. Field yang mengembalikan object (atau root field) tanpa hint sama sekali dapat maxAge 0. Seluruh response memakai maxAge terkecil, dan jadi PRIVATE kalau satu field saja PRIVATE. Header yang dikirim Apollo Server 5:

Query Cache-Control
{ books { title price } } max-age=60, public
{ books { id title stock } } max-age=10, public (stock 10 detik)
{ book(id: 1) { title author { name } } } max-age=60, public
{ authors { name } } max-age=86400, public (dari type Author)
{ books { title } viewer { name } } max-age=30, private

Response dengan error, atau dengan maxAge 0, dapat Cache-Control: no-store. Header ini dipakai CDN dan browser (untuk GET), dan oleh plugin @apollo/server-plugin-response-cache 5.0.0 yang menyimpan seluruh response di memori atau Redis. Response PRIVATE cuma disimpan per pengguna, dan plugin itu butuh fungsi sessionId untuk itu. Hint juga bisa dipasang dari resolver: info.cacheControl.setCacheHint({ maxAge: 30, scope: "PRIVATE" }).

Cache di client: dinormalisasi

Apollo Client 4.3.3 menyimpan response di InMemoryCache. Ia menambahkan __typename ke tiap selection set di query-mu, lalu memecah response jadi satu entri per objek dengan kunci __typename:id. Setelah Katalog, Detail, dan Restock, cache.extract() berisi:

{
  "Book:1": { "__typename": "Book", "id": "1", "title": "Laskar Pelangi", "stock": 12 },
  "Book:2": { "__typename": "Book", "id": "2", "title": "Bumi Manusia", "stock": 7 },
  "Book:3": { "__typename": "Book", "id": "3", "title": "Cantik Itu Luka", "stock": 5 },
  "ROOT_QUERY": {
    "__typename": "Query",
    "books": [{ "__ref": "Book:1" }, { "__ref": "Book:2" }, { "__ref": "Book:3" }],
    "book({\"id\":2})": { "__ref": "Book:2" }
  },
  "ROOT_MUTATION": {
    "__typename": "Mutation",
    "updateStock({\"id\":2,\"stock\":7})": { "__ref": "Book:2" }
  }
}

Batas biaya: depth dan complexity

Satu query bisa bersarang sedalam apa pun, jadi satu request bisa memaksa server bekerja ribuan kali lipat. Dua ukuran dihitung dari teks query, sebelum resolver mana pun jalan:

Query Depth Biaya
Katalog 1 4
Dalam: authors { books { author { books { author { books { title } } } } } } 6 7
Borong: booksConnection(first: 100) { edges { node { title author { name books { title } } } } } 5 800
Borong dengan first: 10 5 80

Biaya Borong dihitung dari dalam: books { title } = 2, author { name books } = 1 + 1 + 2 = 4, node = 1 + 1 + 4 = 6, edges = 7, lalu booksConnection = (1 + 7) × 100 = 800. Dalam murah tapi dalam; Borong dangkal tapi mahal. Karena itu pasang keduanya:

import depthLimit from "graphql-depth-limit";
import { getComplexity, directiveEstimator, simpleEstimator } from "graphql-query-complexity";

const costLimit = {
  async requestDidStart() {
    return {
      async didResolveOperation({ request, document, schema }) {
        const cost = getComplexity({
          schema,
          query: document,
          operationName: request.operationName,
          variables: request.variables, // first: $n dihitung dengan nilai aslinya
          estimators: [directiveEstimator(), simpleEstimator({ defaultComplexity: 1 })],
        });
        if (cost > 200) {
          throw new GraphQLError(`Query too complex: ${cost}. Maximum allowed complexity: 200`, {
            extensions: { code: "QUERY_TOO_COMPLEX", http: { status: 400 } },
          });
        }
      },
    };
  },
};

const server = new ApolloServer({ typeDefs, resolvers, validationRules: [depthLimit(5)], plugins: [costLimit] });

DataLoader tetap per request

Semua cache di atas bekerja antar request. DataLoader (pelajaran "N+1 Problem & DataLoader") bekerja di dalam satu request: penulis yang sama cuma diambil sekali. Tetap buat loader baru di context untuk tiap request; loader global menyimpan data lama dan bisa membocorkan data satu pengguna ke pengguna lain.

⚠️ Jebakan yang sering ketemu

TL;DR: Cache HTTP susah di GraphQL karena semua request POST ke satu endpoint. APQ mengirim SHA-256 dari teks query (PersistedQueryNotFound sekali, lalu POST yang mendaftarkannya, lalu GET berisi hash saja yang bisa disimpan CDN); trusted documents membatasi server ke query yang sudah terdaftar saat build. @cacheControl memberi maxAge per type dan field; response memakai yang terkecil (max-age=10, public kalau ada stock), PRIVATE menular, error atau object tanpa hint jadi no-store. Apollo Client menyimpan tiap objek sekali sebagai __typename:id, jadi mutation yang mengembalikan id dan stock langsung memperbarui semua query yang memakai buku itu. Relay juga menormalkan cache, tapi mewajibkan global ID, Connections, dan fragment yang dikompilasi. Tolak query berat sebelum dijalankan: depthLimit(5) (Dalam, depth 6) dan biaya maksimum 200 di plugin (Borong, 800). DataLoader tetap satu per request.

Yang akan kamu pelajari