DEV Community

Cover image for Persisted GraphQL Queries: Test the APQ Fallback Before Your Interview
Karuha
Karuha

Posted on Originally published at aceround.app

Persisted GraphQL Queries: Test the APQ Fallback Before Your Interview

TL;DR: Persisted GraphQL queries are two different designs that are often described as one. Automatic persisted queries (APQ) can recover from a cache miss by sending the full document once. A strict safelist must reject an unknown hash and never learn from an arbitrary client. Make that distinction executable and your interview answer becomes a production contract instead of a vocabulary test.

A GraphQL interview question about persisted queries sounds simple: “Why send a query hash instead of the whole document?” The shallow answer is bandwidth. The useful answer is about the failure path.

A hash-only request can miss the server’s cache. The client may then retry with the complete operation, depending on the APQ policy. A security-sensitive endpoint may instead accept only build-time registered operations. If you describe these as the same behavior, you will miss the trade-off an interviewer is actually testing: who is allowed to introduce a new query?

This tutorial builds a dependency-free Node.js model for both policies. It uses SHA-256, a tiny in-memory registry, and assertions you can run with node persisted-query-contract.js.

What should happen after a hash miss?

A persisted query maps an identifier to a known GraphQL document. The identifier is commonly a SHA-256 digest of the exact query text. The request can carry only the digest, which makes the payload smaller and gives an intermediary a stable cache key.

There are two legitimate policies:

Policy Unknown hash Full-document retry Typical use
APQ (automatic persisted queries) Return a miss Allowed once, then the server may cache it Controlled first-party clients
Strict safelist Reject Rejected unless registered during deployment Public or high-risk endpoints

Neither policy is “more GraphQL.” They are operational choices. APQ optimizes rollout convenience. A strict safelist optimizes control over query cost and attack surface. The important part is that the client can tell a recoverable miss from a permanent rejection.

Persisted GraphQL query decision flow: hash-only request, known hash, APQ miss retry, or strict safelist rejection

A runnable contract test

Save this as persisted-query-contract.js and run it with Node 18 or newer:

const assert = require("node:assert/strict");
const crypto = require("node:crypto");

const document = "query Viewer { viewer { id name } }";
const hash = (text) => crypto.createHash("sha256").update(text).digest("hex");

class PersistedQueryServer {
  constructor({ strict = false } = {}) {
    this.strict = strict;
    this.allowlist = new Map();
  }

  register(query) {
    const id = hash(query);
    this.allowlist.set(id, query);
    return id;
  }

  execute({ id, query }) {
    const known = this.allowlist.get(id);
    if (known && query && known !== query) return { ok: false, error: "HASH_QUERY_MISMATCH" };
    if (known) return { ok: true, data: { viewer: { id: "42", name: "Ada" } } };
    if (this.strict) return { ok: false, error: "PERSISTED_QUERY_NOT_FOUND" };
    if (!query || hash(query) !== id) return { ok: false, error: "PERSISTED_QUERY_NOT_FOUND" };
    this.allowlist.set(id, query);
    return { ok: true, data: { viewer: { id: "42", name: "Ada" } } };
  }
}

const apq = new PersistedQueryServer();
const id = apq.register(document);
assert.equal(apq.execute({ id }).ok, true, "known hash should execute");

const newDocument = "query New { viewer { email } }";
const miss = apq.execute({ id: hash(newDocument) });
assert.equal(miss.error, "PERSISTED_QUERY_NOT_FOUND");
assert.equal(apq.execute({ id: hash(newDocument), query: newDocument }).ok, true, "APQ may learn a missing query only by policy");
assert.equal(apq.execute({ id: hash(newDocument) }).ok, true, "the next request can send only the hash");

const strict = new PersistedQueryServer({ strict: true });
assert.equal(strict.execute({ id }).error, "PERSISTED_QUERY_NOT_FOUND");
assert.equal(strict.allowlist.size, 0, "strict mode must not learn from clients");
assert.equal(strict.execute({ id, query: document }).error, "PERSISTED_QUERY_NOT_FOUND", "an unregistered document is still rejected");
assert.equal(apq.execute({ id, query: "query Different { viewer { id } }" }).error, "HASH_QUERY_MISMATCH");
console.log("persisted-query contract assertions passed");
Enter fullscreen mode Exit fullscreen mode

The first assertions establish the happy path. A registered query executes with only its hash. An APQ miss returns a sentinel error, then the same hash plus the full document succeeds and becomes available for the next hash-only request.

The strict server behaves differently. It starts with an empty registry. An unknown hash is rejected, and even sending the full document cannot register it at request time. That is the property you want when the server should execute only operations reviewed and shipped by your application.

The final assertion catches a subtle integrity bug: a known hash paired with different query text must fail. Without that check, a proxy, cache layer, or client bug could associate one identifier with another operation.

Why the fallback must be bounded

A common implementation mistake is “retry on every GraphQL error.” That is not an APQ policy. The fallback should happen only for the explicit persisted-query-miss signal, and it should happen at most once.

A safe client sequence is:

  1. Send the operation hash.
  2. If the response says the hash is unknown and the endpoint allows APQ, retry once with the full query and the same hash.
  3. For any other GraphQL error, network error, authorization failure, or validation error, stop or use the endpoint’s normal retry policy.
  4. Record whether the fallback happened so you can measure registry drift and unexpected clients.

The distinction matters for cost. Retrying a validation error repeats work without changing the input. Retrying an authorization error can turn one request into a noisy loop. A bounded, typed fallback is easier to observe and explain.

What changes in a strict safelist?

Strict mode moves registration into deployment. Extract operations from the client build, review the manifest, and load it into the server before traffic arrives. At runtime:

  • Unknown hashes return a stable error such as PERSISTED_QUERY_NOT_FOUND.
  • Full query text is not accepted as an implicit registration request.
  • The server still validates variables, authorization, and resolver cost for known operations.
  • Removing an operation is a deployment change, not a cache eviction accident.

This is not a replacement for authorization. A safelisted query can still expose another user’s record if resolver-level access checks are missing. It is also not a replacement for depth, complexity, or timeout limits. It narrows the set of executable documents; it does not make every permitted document cheap.

For APQ, use an explicit registry policy and a bounded cache. If the registry is shared across instances, give it a durable store and an eviction strategy. If it is per-process memory, a deploy or scale-out can create a wave of misses. That is acceptable only when the client and server have a tested fallback.

How to explain this in an interview

A concise answer can follow four beats:

Contract. “The client sends a SHA-256 operation identifier. Known identifiers execute without the document.”

Failure. “On an APQ miss, the client retries once with the full document. In strict safelist mode, the server rejects and registration happens during deployment.”

Safety. “I verify the hash matches the document, keep authorization and query-cost limits, and never retry unrelated errors.”

Evidence. “I have assertions for known hashes, one-time APQ recovery, strict rejection, and hash/document mismatch.”

That answer shows you understand the protocol at the boundary where systems fail. It leaves room for the follow-up questions interviewers usually ask: how do you roll out a new operation, how do you invalidate a removed one, and what happens when two application versions are live during a deployment?

A practical rehearsal loop

Take one real operation from a project you know. Write down its exact document, variables, expected cost, and the version that introduced it. Then answer these questions out loud:

  • Which clients may send a full document?
  • What metric tells you APQ misses are rising?
  • Can an old mobile build still use the operation after the server deploy?
  • Which error is safe to retry, and which one should fail fast?
  • How would you revoke one query without taking the endpoint down?

You do not need a GraphQL server to rehearse this. The small contract above is enough to expose whether your explanation has a missing branch.

For candidates who want a structured rehearsal partner for technical explanations, AceRound AI — an AI interview assistant — can turn this contract into follow-up prompts and mock rounds. Use it before the interview to practice the explanation; the value is in making the trade-offs your own.

FAQ

Are persisted queries the same as query caching?

No. A persisted-query registry maps an identifier to a document. Response caching stores the result for a particular operation, variables, identity, and freshness policy. You can use one without the other.

Does APQ prevent GraphQL denial-of-service attacks?

Not by itself. APQ reduces repeated document transfer and can improve cacheability. Attackers may still send expensive registered operations, and a permissive APQ server may accept arbitrary documents after fallback. Add authorization, depth or complexity limits, timeouts, and rate limits.

Should every API use strict safelisting?

No. First-party web clients with frequent releases may prefer APQ for rollout flexibility. Public APIs, mobile clients with long upgrade tails, and endpoints with costly resolvers often benefit from a strict manifest. Choose based on who controls clients and how much query cost you can tolerate.

How do I test a real client?

Keep the contract test for policy, then add an integration test against your GraphQL transport. Assert the exact error extension, whether the fallback includes the document, and whether a second request sends only the hash. Test both an old and a new client during a rolling deployment.

Sources

  • GraphQL Learn, “Persisted Queries” (protocol concepts and trade-offs).
  • Apollo GraphQL, “Persisted GraphQL Queries with Apollo Client” (APQ request sequence).
  • GraphQL over HTTP specification (transport and error semantics).
  • Adobe Experience Manager documentation, “Persisted GraphQL queries” (GET/cache behavior).

Disclosure: I used an AI writing assistant to outline and edit this article, then reviewed the code and claims manually.

Top comments (0)