Skip to content
DasPeTe Tutorials

A Fast GraphQL API With Fastify and Yoga · Part 7 of 8 in the series

Part 7: Caching Whole Responses in Valkey

Cache complete query results in Valkey with the response cache of Yoga, clear them when mutations change the data, answer repeated GET requests with 304, and keep the API up when the cache is down.

Quest objectives

  • Cache query results for all instances at once
  • Understand how the cache clears entries by entity
  • Clear lists that a mutation cannot see
  • Answer repeated GET requests with ETags
  • Turn a cache outage into cache misses instead of errors

DataLoader made every query cheaper. The fastest query, though, is the one that never runs. Most of what the guild board is asked for, it was asked a moment ago by someone else, so keep the answer and hand it out again.

How the response cache works

Yoga's response cache builds a key from the operation and its variables and stores the finished result under it. It also notes every entity in the result, each object with its type and id, such as Quest:3. It adds id and __typename to the selections itself, so this works even when the client did not ask for them. When a mutation returns Quest:3, every cached result that contains Quest:3 is thrown away.

The cache lives in Valkey, so every instance reads what any of them stored, and a mutation on one instance clears it for all.

~/projects/guild-api
$ pnpm add @graphql-yoga/plugin-response-cache @envelop/response-cache-redisPackages: +5dependencies:+ @envelop/response-cache-redis 5.0.0+ @graphql-yoga/plugin-response-cache 4.0.1Done in 519ms using pnpm v12.16.1

Create the cache in src/cache.ts:

src/cache.ts
import { createRedisCache } from '@envelop/response-cache-redis';
import { Redis } from 'ioredis';

export const cache = createRedisCache({ redis: new Redis(process.env.REDIS_URL!) });

And add the plugin to src/server.ts:

src/server.ts
import Fastify, { LogController } from 'fastify';
import { createYoga } from 'graphql-yoga';
import { useExtendContextValuePerExecuteSubscriptionEvent } from '@envelop/execute-subscription-event';
import { useResponseCache } from '@graphql-yoga/plugin-response-cache';
import { Logger } from '@graphql-hive/logger';
import { PinoLogWriter } from '@graphql-hive/logger/writers/pino';
import { cache } from './cache.ts';
import { createContext, type Context, type ServerContext } from './context.ts';
import { db } from './db/index.ts';
import { createLoaders } from './loaders.ts';
import { schema } from './schema.ts';

const app = Fastify({
  logger: true,
  logController: new LogController({ disableRequestLogging: true }),
});

const yoga = createYoga<ServerContext>({
  schema,
  context: createContext,
  plugins: [
    // A subscription keeps its context for every event: give each event fresh loaders.
    useExtendContextValuePerExecuteSubscriptionEvent<Context>(() => ({
      contextPartial: { loaders: createLoaders(db) },
    })),
    useResponseCache({
      session: () => null,
      cache,
      ttl: 60_000,
      includeExtensionMetadata: process.env.NODE_ENV !== 'production',
    }),
  ],
  logging: new Logger({ writers: [new PinoLogWriter(app.log)] }),
});

app.route({
  url: yoga.graphqlEndpoint,
  method: ['GET', 'POST', 'OPTIONS'],
  handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply, { req, reply }),
});

await app.listen({ port: Number(process.env.PORT) });
  • session says whose data a result is. null means everybody's: the guild board looks the same to every visitor. As soon as results depend on who asks, return the user's id here, or every visitor gets the first one's answer.

  • ttl is how long a result stays at most, here one minute.

  • includeExtensionMetadata adds to every response whether it came from the cache. That helps while you develop, and production does without it.

Hits and misses

Ask for the same quests twice:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 3) { title status assignee { name } } }"}'{"data":{"quests":[{"title":"Slay the cellar rats","status":"DONE","assignee":{"name":"Brakka"}},{"title":"Escort the spice caravan","status":"ACCEPTED","assignee":{"name":"Ilse"}},{"title":"Find the lost forge hammer","status":"OPEN","assignee":null}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 3) { title status assignee { name } } }"}'{"data":{"quests":[{"title":"Slay the cellar rats","status":"DONE","assignee":{"name":"Brakka"}},{"title":"Escort the spice caravan","status":"ACCEPTED","assignee":{"name":"Ilse"}},{"title":"Find the lost forge hammer","status":"OPEN","assignee":null}]},"extensions":{"responseCache":{"hit":true}}}

The first answer was stored, the second came from the cache. The log shows the difference:

Terminal
Query: select "id", "title", "reward", "status", "guild_id", "assignee_id" from "quests" order by "quests"."id" limit $1 -- params: [3]Query: select "id", "name", "class", "level", "guild_id" from "adventurers" where "adventurers"."id" in ($1, $2) -- params: [1, 2][15:27:05.344] INFO (189): Request processed    requestId: "req-1"    method: "POST"    path: "/graphql"    operationType: "query"    status: 200    errorCount: 0    durationMs: 25[15:27:05.582] INFO (189): Request processed    requestId: "req-2"    method: "POST"    path: "/graphql"    status: 200    errorCount: 0    durationMs: 1

Two SQL queries and 25 milliseconds for the first request, no SQL and one millisecond for the second. The second line has no operationType, because the cache answered before any operation ran.

Mutations clear the cache

Accept the third quest and ask again:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { acceptQuest(questId: 3, adventurerId: 3) { status } }"}'{"data":{"acceptQuest":{"status":"ACCEPTED"}},"extensions":{"responseCache":{"invalidatedEntities":[{"typename":"Quest","id":"3"}]}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 3) { title status assignee { name } } }"}'{"data":{"quests":[{"title":"Slay the cellar rats","status":"DONE","assignee":{"name":"Brakka"}},{"title":"Escort the spice caravan","status":"ACCEPTED","assignee":{"name":"Ilse"}},{"title":"Find the lost forge hammer","status":"ACCEPTED","assignee":{"name":"Corin"}}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}

The mutation returned Quest:3, so the cached list that contained it was thrown away, and the next request read the new status from the database.

What a mutation cannot see

Now the open quests, a new quest, and the open quests again:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(status: OPEN) { title } }"}'{"data":{"quests":[{"title":"Tame the volcano drake"},{"title":"Recover the pearl crown"},{"title":"Clear the harbour of krakenlings"},{"title":"Wake the old treant"}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { postQuest(input: { guildId: 1, title: \"Bake the ember bread\", reward: 10 }) { id } }"}'{"data":{"postQuest":{"id":"11"}},"extensions":{"responseCache":{"invalidatedEntities":[{"typename":"Quest","id":"11"}]}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(status: OPEN) { title } }"}'{"data":{"quests":[{"title":"Tame the volcano drake"},{"title":"Recover the pearl crown"},{"title":"Clear the harbour of krakenlings"},{"title":"Wake the old treant"}]},"extensions":{"responseCache":{"hit":true}}}

The new quest is missing, and the list comes from the cache. The mutation returned Quest:11, but the cached list was stored before that quest existed, so it does not contain it, and nothing connects the two. The same happens to the quest list of an adventurer who accepts a quest, unless the client happens to select the adventurer in the mutation.

The resolvers know what changed, so they clear it themselves. Import the cache in src/resolvers.ts:

src/resolvers.ts
import { cache } from './cache.ts';

In postQuest, clear every result with a quest in it:

src/resolvers.ts
postQuest: async (_parent, { input }, { db, loaders, pubsub }) => {
      const title = input.title.trim();
      if (!title) throw badInput('A quest needs a title');
      if (input.reward < 1) throw badInput('The reward must be at least 1 gold');

      const guild = await loaders.guild.load(Number(input.guildId)).catch(() => null);
      if (!guild) throw badInput(`There is no guild with the id ${input.guildId}`);

      const [quest] = await db
        .insert(quests)
        .values({ title, reward: input.reward, guildId: guild.id })
        .returning();
      // A new quest belongs in cached lists that cannot know about it yet.
      await cache.invalidate([{ typename: 'Quest' }, { typename: 'Guild', id: guild.id }]);
      pubsub.publish('quest:posted', quest);
      return quest;
    },

An entity without an id, { typename: 'Quest' }, stands for every quest. Guild with its id also clears results that show the guild without any of its quests. In acceptQuest, clear the adventurer:

src/resolvers.ts
acceptQuest: async (_parent, { questId, adventurerId }, { db, loaders, pubsub }) => {
      const adventurer = await loaders.adventurer.load(Number(adventurerId)).catch(() => null);
      if (!adventurer) throw badInput(`There is no adventurer with the id ${adventurerId}`);

      const [quest] = await db
        .update(quests)
        .set({ status: 'ACCEPTED', assigneeId: adventurer.id })
        .where(and(eq(quests.id, Number(questId)), eq(quests.status, 'OPEN')))
        .returning();
      if (!quest) throw badInput(`Quest ${questId} is not open`);

      // The adventurer's own quest list changed too.
      await cache.invalidate([{ typename: 'Adventurer', id: adventurer.id }]);

      pubsub.publish('quest:updated', quest.id, quest);
      return quest;
    },

Post one more quest:

~/projects/guild-api
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"mutation { postQuest(input: { guildId: 1, title: \"Sweep the ash\", reward: 10 }) { id } }"}'{"data":{"postQuest":{"id":"12"}},"extensions":{"responseCache":{"invalidatedEntities":[{"typename":"Quest","id":"12"}]}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(status: OPEN) { title } }"}'{"data":{"quests":[{"title":"Tame the volcano drake"},{"title":"Recover the pearl crown"},{"title":"Clear the harbour of krakenlings"},{"title":"Wake the old treant"},{"title":"Bake the ember bread"},{"title":"Sweep the ash"}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}

Both new quests are on the board.

ETags

The cache also helps browsers and CDNs. For a cached result, Yoga sends an etag, a fingerprint of the answer. A client that sends it back in if-none-match gets 304 Not Modified without a body, as long as the answer is still the same:

~/projects/guild-api
$ curl -si 'localhost:4000/graphql?query=%7Bguilds%7Bname%7D%7D' | grep -iE '^(HTTP|etag|last-modified)'HTTP/1.1 200 OKetag: e90f0282165db81fff145699757eaedf0c13003ce5100e8a4132b0c0b0fc77a5last-modified: Tue, 03 Nov 2026 15:27:26 GMT$ curl -si 'localhost:4000/graphql?query=%7Bguilds%7Bname%7D%7D' -H 'if-none-match: e90f0282165db81fff145699757eaedf0c13003ce5100e8a4132b0c0b0fc77a5' | head -1HTTP/1.1 304 Not Modified

That only works for GET requests, which browsers and CDNs cache, and a whole query in the address gets long. The next part makes those addresses short.

When the cache is down

Stop Valkey and ask for a guild:

~/projects/guild-api
$ docker compose stop valkey Container guild-api-valkey-1 Stopping Container guild-api-valkey-1 Stopped$ curl -s -w '\n%{time_total}s\n' localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guild(id: 2) { name } }"}'{"errors":[{"message":"Unexpected error.","extensions":{"code":"INTERNAL_SERVER_ERROR"}}]}7.452852s

Seven and a half seconds, then an error. ioredis queues the cache lookup and tries to reconnect 20 times before it gives up, and the request waits for it. A cache is supposed to make the API faster. With Valkey down it takes the API down with it.

Wrap the cache, so a failing Valkey means a cache miss. Replace src/cache.ts:

src/cache.ts
import type { Cache } from '@graphql-yoga/plugin-response-cache';
import { createRedisCache } from '@envelop/response-cache-redis';
import { Redis } from 'ioredis';

// While Valkey is away, fail cache commands at once instead of queueing them.
const redis = new Redis(process.env.REDIS_URL!, { enableOfflineQueue: false });
redis.on('error', (error: NodeJS.ErrnoException) => {
  console.warn(`Valkey cache: ${error.code ?? error.message}`);
});

const valkeyCache = createRedisCache({ redis });

/** The Valkey cache, but an outage means cache misses instead of failed requests. */
export const cache: Cache = {
  async get(key) {
    try {
      return await valkeyCache.get(key);
    } catch {
      return undefined;
    }
  },
  async set(key, result, entities, ttl) {
    try {
      await valkeyCache.set(key, result, entities, ttl);
    } catch {
      // Not cached this time, the next request tries again.
    }
  },
  async invalidate(entities) {
    try {
      await valkeyCache.invalidate(entities);
    } catch {
      // What was cached before the outage expires with its TTL.
    }
  },
};

With enableOfflineQueue: false, ioredis rejects commands at once while it is disconnected, instead of queueing them. The wrapper catches that: a failed get is a miss, a failed set is skipped. This is the opposite of the PubSub in the last part, which waits for Valkey, because a lost event is a bug and a lost cache entry is just a slower answer. Stop Valkey again:

~/projects/guild-api
$ docker compose stop valkey Container guild-api-valkey-1 Stopping Container guild-api-valkey-1 Stopped$ curl -s -w '\n%{time_total}s\n' localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guild(id: 2) { name } }"}'{"data":{"guild":{"name":"Tidecallers"}},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}0.034335s

34 milliseconds, straight from PostgreSQL. When Valkey is back, the cache fills up again by itself.

Reads are now cheap, cached and shared. In the last part you protect the API from the queries that would be expensive anyway: too deep, too broad, or simply not yours.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.