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 menjalankanmutation 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}, ...]}}
- Server menghitung ulang hash dari teks di langkah 2. Kalau gak cocok, jawabannya 400
provided sha does not match query. - Hash baru disimpan setelah query-nya lolos validasi. Query yang ditolak gak pernah terdaftar.
- Hasilnya: request kecil, dan URL di langkah 3 selalu sama, jadi CDN bisa menyimpannya. Jadi persisted query artinya query disimpan di server dengan hash sebagai ID-nya, dan client cuma mengirim hash-nya.
- APQ bukan pengaman, karena siapa pun bisa mendaftarkan query baru lewat langkah 2. Untuk menolak query asing, pakai trusted documents (disebut juga persisted query list atau safelist): saat build, semua operation di kode client dikumpulkan jadi satu manifest (misalnya dengan
@apollo/generate-persisted-query-manifest1.4.0), dan server cuma mau menjalankan hash yang ada di daftar itu.
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" }
}
}
- Bumi Manusia cuma disimpan sekali, di
Book:2.booksdanbook({"id":2})cuma berisi rujukan (__ref) ke sana. - Restock mengembalikan
iddanstock, jadi Apollo tahu yang berubah adalahBook:2dan menggantistock-nya jadi 7. Katalog dibaca lewat rujukan yang sama, jadi daftar di layar ikut jadi 7 tanpa request apa pun. Itu jawaban tebakannya. Kalau mutation-nya gak memintaid, cache gak tahu entri mana yang harus diubah dan layar tetap basi. - Inilah yang membedakan Apollo Client dari
fetchbiasa: cache yang dinormalisasi otomatis, state yang reaktif (hook sepertiuseQueryme-render ulang komponen saat entrinya berubah), dan status loading serta error yang sudah terurus. - urql 5 punya ide yang sama lewat
@urql/exchange-graphcache9.0.2. Tanpa Graphcache, urql cuma menyimpan per query dan variable. - Relay, client GraphQL dari Meta, juga menormalkan cache-nya, tapi lebih ketat: ia mewajibkan global ID (interface
Nodedi pelajaran "Schema Design Patterns"), Connections untuk pagination, dan fragment yang ditaruh bersebelahan dengan komponennya, lalu dikompilasi saat build. Karena pola itu wajib, performanya optimal secara default; harganya, belajarnya lebih berat daripada Apollo.
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:
- Depth (
graphql-depth-limit1.1.0): tiap field yang punya selection set menambah satu level, field skalar gak menambah.Katalogkedalamannya 1. - Complexity (
graphql-query-complexity2.0.0): tiap field bernilai 1 ditambah isi di dalamnya. Field list yang diberi@complexity(value: 1, multipliers: ["first"])dihitung (1 + isinya) ×first.
| 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] });
depthLimit(5)ikut validasi. Dalam ditolak dengan status 400:'Dalam' exceeds maximum operation depth of 5(kodeGRAPHQL_VALIDATION_FAILED).- Biaya dihitung di plugin karena validation rule gak melihat
variables. Borong ditolak:Query too complex: 800. Maximum allowed complexity: 200. Tanpahttp: { status: 400 }, error yang dilempar darididResolveOperationdikirim dengan status 500. - Field
@complexitydi SDL harus dideklarasikan:directive @complexity(value: Int!, multipliers: [String!]) on FIELD_DEFINITION.
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
- Mutation yang gak mengembalikan
id. Cache client gak bisa menggabungkannya, jadi layar tetap menampilkan data lama. - Satu type object tanpa hint. Field-nya dapat
maxAge0, dan seluruh response jadino-store. - Detail gak dijawab dari cache.
Book:2sudah ada, tapibook(id: 2)tetap ke network karenaROOT_QUERYbelum punyabook({"id":2}). Arahkan dengantypePolicies:book: { read: (_, { args, toReference }) => toReference({ __typename: "Book", id: args.id }) }. - APQ dianggap pengaman, atau list tanpa batas. APQ menerima query baru dari siapa pun; pakai trusted documents. Dan
bookstanpalimitdihitung ×1 berapa pun isinya, jadi beri tiap list argument batas dengan nilai maksimum.
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.