---
title: 'Query, Mutation, and Subscription'
source: 'https://academia.sh/en/courses/api-design/query-mutation-and-subscription'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:29+00:00'
license: 'CC BY-SA 4.0'
---

# Query, Mutation, and Subscription

The three operation types' execution rules, the measured result of running mutation fields sequentially on a single-copy book, and a subscription applying the same selection set more than once over time.

The schema says what can be asked, but not where the response will come from. This lesson
writes the engine and shows what separates GraphQL's three **operation types**.

The difference is not in syntax; all three use the same selection set language. The
difference is in the execution rules. Query fields are assumed independent of each other
and can run together. Mutation fields can affect each other and must run **in order**.
Subscription produces not one response but more than one over time.

## The Engine

The engine walks the selection set, calls a resolver for each field, and completes the
returned value according to the field's type. A leaf type's value returns as is; a
composite type's subselection runs through the same procedure.

```js
// executor.mjs — runs a validated document
// Query fields run together, mutation fields run in order.
import { parseType, fieldDef, possibleTypes } from "./schema.mjs";

const LEAF = new Set(["scalar", "enum"]);

// Merges variable declarations with the values supplied.
function resolveVariables(op, given) {
  const d = {};
  for (const b of op.variables) {
    const v = given[b.name] ?? b.defaultValue;
    if (v === undefined && b.type.endsWith("!")) throw new Error(`variable $${b.name} is required`);
    d[b.name] = v;
  }
  return d;
}

const resolveArgs = (args, vars) =>
  Object.fromEntries(Object.entries(args).map(([a, d]) => [a, d.kind === "variable" ? vars[d.name] : d.value]));

// Opens fragments and inline fragments; drops branches that do not match the concrete type.
function flatten(schema, document, typeName, selection, acc = []) {
  for (const s of selection) {
    if (s.kind === "field") { acc.push(s); continue; }
    const fragment = s.kind === "spread" ? document.fragments[s.name] : s;
    const condition = s.kind === "spread" ? fragment.type : s.type;
    if (condition === typeName || possibleTypes(schema, condition).includes(typeName)) flatten(schema, document, typeName, fragment.selection, acc);
  }
  return acc;
}

async function executeSelection(o, typeName, selection, source, sequential = false) {
  const fields = flatten(o.schema, o.document, typeName, selection);
  const result = {};
  for (const f of fields) result[f.alias] = null;      // key order comes from the query
  const run = async (f) => { result[f.alias] = await executeField(o, typeName, f, source); };
  if (sequential) for (const f of fields) await run(f);
  else await Promise.all(fields.map(run));
  return result;
}

async function executeField(o, typeName, field, source) {
  const def = fieldDef(o.schema.types[typeName], field.name);
  const resolver = o.resolvers[typeName]?.[field.name] ?? ((k) => k?.[field.name]);
  const value = await resolver(source, resolveArgs(field.args, o.vars), o.context);
  return complete(o, def.type, value, field.selection);
}

async function complete(o, typeText, value, selection) {
  if (value === null || value === undefined) return null;
  const t = parseType(typeText);
  if (t.list) return Promise.all(value.map((d) => complete(o, t.name, d, selection)));
  const type = o.schema.types[t.name];
  if (LEAF.has(type.kind)) return value;
  // Interface and union types ask the concrete-type resolvers.
  const concrete = type.kind === "object" ? t.name : o.resolvers.__type[t.name](value);
  return executeSelection(o, concrete, selection, value);
}

export async function execute(schema, resolvers, document, { operationName, variables = {}, context = {} } = {}) {
  const op = operationName ? document.operations.find((i) => i.name === operationName) : document.operations[0];
  const o = { schema, resolvers, document, vars: resolveVariables(op, variables), context };
  const root = op.type === "mutation" ? schema.mutation : schema.query;
  return { data: await executeSelection(o, root, op.selection, null, op.type === "mutation") };
}

// Subscription: the resolver returns an event stream, each event formatted with the same selection set.
export async function* subscribe(schema, resolvers, document, { variables = {}, context = {} } = {}) {
  const op = document.operations[0];
  const o = { schema, resolvers, document, vars: resolveVariables(op, variables), context };
  const field = flatten(schema, document, schema.subscription, op.selection)[0];
  const stream = resolvers[schema.subscription][field.name](null, resolveArgs(field.args, o.vars), context);
  const def = fieldDef(schema.types[schema.subscription], field.name);
  for await (const event of stream) yield { data: { [field.alias]: await complete(o, def.type, event, field.selection) } };
}
```

The only branch in the `execute` function is whether the operation type is a mutation,
and that branch passes through to the `executeSelection` call as the `sequential` flag.
That is the rule: the entire difference is the choice between a single `for` loop and
`Promise.all`.

The engine needs resolvers to reach the data. The data source below is in-memory, and the
loan-issuing operation is deliberately written to be realistic: the remaining copy count
is **read**, there is a wait as long as a database round trip, then the record is
**written**.

```js
// data.mjs — in-memory loan data and resolvers
export const BOOKS = new Map([
  ["978-0262033848", { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen", copies: 1 }],
  ["978-0201896831", { isbn: "978-0201896831", title: "The Art of Computer Programming", author: "Knuth", copies: 2 }],
]);
export const MEMBERS = new Map([
  ["U-1001", { id: "U-1001", createdAt: "2024-02-11", name: "Alice Carter" }],
  ["U-1002", { id: "U-1002", createdAt: "2025-06-03", name: "Dana Reyes" }],
]);
export const LOANS = new Map();
let sequence = 0;

export const wait = (ms) => new Promise((c) => setTimeout(c, ms));
export const events = [];   // to see the execution order
export const reset = () => { LOANS.clear(); sequence = 0; events.length = 0; };

const onLoanCount = (isbn) => [...LOANS.values()].filter((o) => o.status === "OPEN" && o.items.some((k) => k.isbn === isbn)).length;

export function issueLoan(member, isbn, delay = 30) {
  return (async () => {
    events.push(`issueLoan(${isbn.slice(-4)}) started`);
    const remaining = BOOKS.get(isbn).copies - onLoanCount(isbn);   // READ
    await wait(delay);                                             // database round trip
    if (remaining <= 0) { events.push(`issueLoan(${isbn.slice(-4)}) finished: no copy available`); return null; }
    const record = { id: `O-${++sequence}`, createdAt: "2026-03-01", memberId: member,   // WRITE
                    items: [{ isbn, branch: "central" }], status: "OPEN", returnDate: null };
    LOANS.set(record.id, record);
    events.push(`issueLoan(${isbn.slice(-4)}) finished: ${record.id}`);
    return record;
  })();
}

export const RESOLVERS = {
  __type: { Record: (d) => (d.items ? "Loan" : "Member"), SearchResult: (d) => (d.isbn ? "Book" : "Member") },
  Query: {
    loan: (_, a) => LOANS.get(a.id) ?? null,
    member: (_, a) => MEMBERS.get(a.code) ?? null,
    record: (_, a) => LOANS.get(a.id) ?? MEMBERS.get(a.id) ?? null,
    search: (_, a) => [...BOOKS.values()].filter((k) => k.title.toLowerCase().includes(a.term.toLowerCase())),
  },
  Mutation: {
    issueLoan: (_, a) => issueLoan(a.member, a.isbn),
    returnLoan: (_, a) => { const o = LOANS.get(a.id); if (o) o.status = "CLOSED"; return o ?? null; },
  },
  Loan: {
    member: (o) => MEMBERS.get(o.memberId),
    items: (o) => o.items,
  },
  Item: { book: (k) => BOOKS.get(k.isbn) },
  Member: { loans: (u, a) => [...LOANS.values()].filter((o) => o.memberId === u.id && (!a.status || o.status === a.status)) },
};
```

## The Three Operation Types Side by Side

The schema built in the Schema and Type System lesson had two roots: query and mutation.
Subscription is the third root, and it is added to the schema as a type; its fields name
the events the client can watch.

```js
// schema-subscription.mjs — the schema from lesson 01 with a subscription root added
import { SCHEMA } from "./schema.mjs";

export const SCHEMA_S = {
  ...SCHEMA,
  subscription: "Subscription",
  types: {
    ...SCHEMA.types,
    Subscription: {
      kind: "object",
      fields: { loanStatus: { type: "Loan!", args: { id: "ID!" } } },
    },
  },
};
```

```js
// run.mjs — runs all three operation types; compares query and mutation execution order
import { parse } from "./parser.mjs";
import { execute, subscribe } from "./executor.mjs";
import { SCHEMA_S as SCHEMA } from "./schema-subscription.mjs";
import { RESOLVERS, LOANS, events, wait, reset } from "./data.mjs";

const log = (b, d) => console.log(b, JSON.stringify(d));

// 1) QUERY — fields run together
LOANS.set("O-9", { id: "O-9", createdAt: "2026-02-01", memberId: "U-1001",
  items: [{ isbn: "978-0201896831", branch: "shore" }], status: "OPEN", returnDate: "2026-03-20" });

const query = parse(`query($id: ID!) {
  loan(id: $id) { id status member { name } items { book { title } branch } }
  record(id: "U-1002") { id createdAt ... on Member { name } }
}`);
log("query        ->", (await execute(SCHEMA, RESOLVERS, query, { variables: { id: "O-9" } })).data);

// 2) MUTATION — try issuing a single-copy book twice
const mutation = parse(`mutation {
  first:  issueLoan(member: "U-1001", isbn: "978-0262033848") { id status }
  second: issueLoan(member: "U-1002", isbn: "978-0262033848") { id status }
}`);
reset();
log("mutation     ->", (await execute(SCHEMA, RESOLVERS, mutation)).data);
console.log("  execution order:", events.join(" | "));

// If the same two fields had run together:
reset();
const parallel = await Promise.all([
  RESOLVERS.Mutation.issueLoan(null, { member: "U-1001", isbn: "978-0262033848" }),
  RESOLVERS.Mutation.issueLoan(null, { member: "U-1002", isbn: "978-0262033848" }),
]);
console.log("  if run together:", events.join(" | "));
console.log("  copies issued if run together:", parallel.filter(Boolean).length, "(the book has 1 copy)");

// 3) SUBSCRIPTION — the same selection set is applied more than once over time
RESOLVERS.Subscription = {
  loanStatus: async function* (_, a) {
    for (const d of ["OPEN", "OVERDUE", "CLOSED"]) {
      await wait(5);
      const o = LOANS.get(a.id);
      yield { ...o, status: d };
    }
  },
};
LOANS.set("O-9", { id: "O-9", createdAt: "2026-02-01", memberId: "U-1001",
  items: [{ isbn: "978-0201896831", branch: "shore" }], status: "OPEN", returnDate: "2026-03-20" });
const subscription = parse(`subscription { loanStatus(id: "O-9") { id status member { name } } }`);
for await (const event of subscribe(SCHEMA, RESOLVERS, subscription)) log("subscription ->", event.data);
```

```
query        -> {"loan":{"id":"O-9","status":"OPEN","member":{"name":"Alice Carter"},"items":[{"book":{"title":"The Art of Computer Programming"},"branch":"shore"}]},"record":{"id":"U-1002","createdAt":"2025-06-03","name":"Dana Reyes"}}
mutation     -> {"first":{"id":"O-1","status":"OPEN"},"second":null}
  execution order: issueLoan(3848) started | issueLoan(3848) finished: O-1 | issueLoan(3848) started | issueLoan(3848) finished: no copy available
  if run together: issueLoan(3848) started | issueLoan(3848) started | issueLoan(3848) finished: O-1 | issueLoan(3848) finished: O-2
  copies issued if run together: 2 (the book has 1 copy)
subscription -> {"loanStatus":{"id":"O-9","status":"OPEN","member":{"name":"Alice Carter"}}}
subscription -> {"loanStatus":{"id":"O-9","status":"OVERDUE","member":{"name":"Alice Carter"}}}
subscription -> {"loanStatus":{"id":"O-9","status":"CLOSED","member":{"name":"Alice Carter"}}}
```

## Query: Fields Are Independent

The two root fields in the query response cannot see each other. The order in which the
`loan` and `record` fields are resolved does not change the result, because both only
read. This independence assumption is what legitimizes GraphQL running root query fields
together, and in practice it is a clear gain: a screen with four independent root fields
waits for the slowest one, not for the combined duration of four separate requests.

The key order in the response, however, is the order in the query, not the resolution
order. The engine ensures this by placing the fields with `null` in advance. The client's
ability to read the response the way it read the query depends on this.

The `record` field returns an interface, and the `... on Member { name }` branch ran.
What the concrete type is gets asked of the `__type` resolver; the schema cannot know
which type a record in the data source is, so code that reports this has to be written.

## Mutation: Order Is a Guarantee

The mutation output shows the heart of the matter. A single-copy book was requested as a
loan twice; `first` succeeded, `second` returned `null`. The execution-order line tells
why: the second resolver started only after the first one **finished**, and it saw zero
copies remaining.

When the same two resolvers run together, the order changes: both start, both read the
same single copy at the start, and both write a record. The single-copy book ends up
issued as a loan twice. This is the same lost-update problem from the database lessons,
and it is why GraphQL runs root mutation fields in order.

The guarantee's boundary must also be stated: sequencing is only for root mutation
fields, and it only holds within a single operation. Two separate requests arriving at
the same time can still have their mutations collide; what prevents that is the
resolver's own concurrency control. In-operation ordering guarantees that the steps a
client wrote in a single document will run in the order it knew — nothing else.

## Subscription: One Selection, Many Responses

The three lines in the subscription output were all produced from the same selection
set. The resolver returns not a single value but an **event stream**; the engine formats
every event with the same selection set. The `member { name }` branch was resolved three
times.

This is the structural difference: in query and mutation, the root field resolves to a
value; in subscription, to a stream. This has two consequences. A subscription operation
can hold only one root field — there is no defined answer for how streams would be
merged. And the transport layer cannot be request–response; it needs a carrier that can
deliver more than one response over a single request.

## Summary

- The three operation types use the same selection set language; where they diverge is
  the execution rules.
- Query root fields are assumed independent and run together; the key order in the
  response is still the order in the query.
- Mutation root fields run in order; a single-copy book is issued once under sequential
  execution and twice when run together.
- The sequencing guarantee is only for root mutation fields and only within a single
  operation; preventing separate requests from colliding is the resolver's job.
- A subscription resolver returns not a value but an event stream; the same selection set
  is reapplied to every event.
- A subscription can hold only one root field, and its transport layer cannot be
  request–response.

## Next Step

The engine called a resolver for every field, but how resolvers are written was not
discussed. The loan record's `member` field has a separate resolver, the `id` field does
not; what is the difference between the two? What information does a resolver access, in
what order is it called, and how does its returned value pass to the next level's
resolver? The next lesson defines the resolver signature in its four parts, shows what
the default resolver does, and lays out the shape of execution by counting how many
resolver calls a query produces.
