---
title: 'Comparison of API Styles'
source: 'https://academia.sh/en/courses/api-design/comparison-of-api-styles'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:28+00:00'
license: 'CC BY-SA 4.0'
---

# Comparison of API Styles

Three styles that meet the same job: resource-based, remote-procedure-call style, and query-based approaches are written for the same scenario and compared by measuring request count, bytes on the wire, and the used share of carried fields; the lesson shows which side has to change when the requirement changes.

The previous lesson established what makes an interface a contract but left the contract's
shape open. The following job requested from the library service can be met by three
separate approaches: **list a member's borrowed books, along with their titles and due
dates.**

The three approaches are as follows. In the **resource-based** style, every concept has
its own address, and the client collects the necessary pieces one by one. In the
**remote-procedure-call** style, there is a single address; the client sends a method name
in the body, and the server returns the response prepared for that job. In the
**query-based** style, there is again a single address, but the client writes which fields
it wants in the body. The difference among them is not a matter of taste: who determines
the response shape changes, and this has measurable consequences.

## Shared Data and the Third Style's Parser

All three styles work on the same data.

```js
// data.mjs — the shared library data all three styles run on
export const MEMBERS = [{ id: 1, name: "Alice Kane", email: "alice@example.test", phone: "0000000000",
  address: "Central Branch, Floor 2", registered: "2024-01-05", fine: 0, membershipType: "full" }];
export const BOOKS = {
  "978-0262033848": { isbn: "978-0262033848", title: "Introduction to Algorithms", author: "Cormen",
    pages: 1312, publisher: "MIT", year: 2009, shelf: "R-12", summary: "Algorithm design and analysis." },
  "978-0201896831": { isbn: "978-0201896831", title: "The Art of Computer Programming", author: "Knuth",
    pages: 650, publisher: "Addison", year: 1997, shelf: "R-03", summary: "The fundamental algorithms volume." },
  "978-0131103627": { isbn: "978-0131103627", title: "The C Programming Language", author: "Kernighan",
    pages: 272, publisher: "Prentice", year: 1988, shelf: "R-07", summary: "The language's definition and examples." },
};
export const LOANS = [
  { id: 11, memberId: 1, isbn: "978-0262033848", borrowedAt: "2024-05-01", dueDate: "2024-05-15" },
  { id: 12, memberId: 1, isbn: "978-0201896831", borrowedAt: "2024-05-03", dueDate: "2024-05-17" },
  { id: 13, memberId: 1, isbn: "978-0131103627", borrowedAt: "2024-05-06", dueDate: "2024-05-20" },
];
```

The query-based style requires a parser: the selection text the client writes is converted
into a tree, and this tree is then executed against the data. The following thirty lines
do this job.

```js
// query.mjs — a small selection-set parser. Input: "member(1) { name loans { dueDate book { title } } }"
export const parse = (text) => {
  const tokens = text.match(/[A-Za-z_][A-Za-z0-9_]*|\d+|[{}()]/g) ?? [];
  let i = 0;
  const selectionSet = () => {                       // { field field(arg) { ... } ... }
    const fields = [];
    i++;                                             // opening curly brace
    while (tokens[i] !== "}") {
      const name = tokens[i++];
      let arg = null;
      if (tokens[i] === "(") { arg = tokens[i + 1]; i += 3; }
      const sub = tokens[i] === "{" ? selectionSet() : null;
      fields.push({ name, arg, sub });
    }
    i++;                                             // closing curly brace
    return fields;
  };
  tokens.unshift("{"); tokens.push("}");
  return selectionSet();
};

// Executes the selection tree against the data: only the fields the client asked for come back.
export const execute = (fields, root, resolver) => {
  const output = {};
  for (const field of fields) {
    const value = resolver(root, field.name, field.arg);
    if (!field.sub) { output[field.name] = value; continue; }
    output[field.name] = Array.isArray(value)
      ? value.map((d) => execute(field.sub, d, resolver))
      : execute(field.sub, value, resolver);
  }
  return output;
};
```

All three styles sit side by side in a single server; this way the comparison happens on
the same data, the same process, and the same connection.

```js
// server.mjs — a server that meets the same job with three styles: resource-based, remote procedure, query-based
import { createServer } from "node:http";
import { MEMBERS, BOOKS, LOANS } from "./data.mjs";
import { parse, execute } from "./query.mjs";

const readBody = (req) => new Promise((resolve) => {
  let m = ""; req.on("data", (p) => (m += p)); req.on("end", () => resolve(m));
});
const json = (res, code, body) => {
  const text = JSON.stringify(body);
  res.setHeader("Content-Type", "application/json; charset=utf-8");
  res.setHeader("Content-Length", Buffer.byteLength(text));
  res.writeHead(code).end(text);
};

// Resolver for the query-based style: each field name corresponds to a data access.
const resolver = (root, name, arg) => {
  if (name === "member") return MEMBERS.find((m) => m.id === Number(arg));
  if (name === "loans") return LOANS.filter((l) => l.memberId === root.id);
  if (name === "book") return BOOKS[root.isbn];
  return root[name];
};

createServer(async (req, res) => {
  res.sendDate = false;
  const path = new URL(req.url, "http://local").pathname;

  // 1) Resource-based: each concept has a separate address, each address returns the full record.
  let e;
  if ((e = /^\/members\/(\d+)$/.exec(path))) return json(res, 200, MEMBERS.find((m) => m.id === +e[1]));
  if ((e = /^\/members\/(\d+)\/loans$/.exec(path)))
    return json(res, 200, LOANS.filter((l) => l.memberId === +e[1]));
  if ((e = /^\/books\/(.+)$/.exec(path))) return json(res, 200, BOOKS[e[1]]);

  // 2) Remote procedure call: single address, method name in the body; the server decides the response shape.
  if (path === "/rpc") {
    const { method, params } = JSON.parse(await readBody(req));
    if (method !== "memberLoanSummary") return json(res, 200, { error: "unknown method" });
    const member = MEMBERS.find((m) => m.id === params.memberId);
    return json(res, 200, { result: { name: member.name, loans: LOANS
      .filter((l) => l.memberId === member.id)
      .map((l) => ({ dueDate: l.dueDate, bookTitle: BOOKS[l.isbn].title })) } });
  }

  // 3) Query-based: single address, selection set in the body; the client decides the response shape.
  if (path === "/query") {
    const { query } = JSON.parse(await readBody(req));
    return json(res, 200, { data: execute(parse(query), null, resolver) });
  }
  return json(res, 404, { error: "not found" });
}).listen(8451, "127.0.0.1", () => console.log("three styles 127.0.0.1:8451"));
```

## Three Measures

The comparison rests on three numbers. **Request count** gives the network round trips.
**Bytes on the wire**, headers included, gives the data actually carried; to measure this,
the client uses a layer that wraps the connection. **Carried field count** is the sum of
the scalar values in the responses; how many of these are used quantifies **over-fetching**.

```js
// measure.mjs — measures the three styles in the same scenario: request count, bytes on the wire, used-field ratio
import { Agent, request } from "node:http";
import { connect } from "node:net";

let sent = 0, received = 0, requestCount = 0;
const agent = new Agent({ keepAlive: true, maxSockets: 1 });
agent.createConnection = (option) => {                 // connection that counts bytes on the wire
  const s = connect(option);
  s.on("data", (p) => { received += p.length; });
  const write = s.write.bind(s);
  s.write = (chunk, ...k) => { sent += Buffer.byteLength(chunk); return write(chunk, ...k); };
  return s;
};
const sendRequest = (path, body) => new Promise((resolve, reject) => {
  requestCount++;
  const r = request({ agent, host: "127.0.0.1", port: 8451, path,
    method: body ? "POST" : "GET", headers: body ? { "Content-Type": "application/json" } : {} },
    (y) => { let m = ""; y.on("data", (p) => (m += p)); y.on("end", () => resolve(JSON.parse(m))); });
  r.on("error", reject);
  r.end(body ? JSON.stringify(body) : undefined);
});

// Sum of scalar fields carried in the responses
const countLeaves = (d) => (typeof d !== "object" || d === null
  ? 1 : Object.values(d).reduce((t, v) => t + countLeaves(v), 0));
const reset = () => { sent = received = requestCount = 0; };
const report = (name, carried, used, note) => console.log(
  `${name.padEnd(17)} requests=${String(requestCount).padStart(2)}  ` +
  `sent=${String(sent).padStart(4)} B  received=${String(received).padStart(4)} B  ` +
  `carried fields=${String(carried).padStart(2)}  used=` +
  (carried ? `%${Math.round((100 * used) / carried)}`.padStart(5) : "    -") +
  (note ? `\n${" ".repeat(18)}${note}` : ""));

const resourceBased = async () => {
  reset();
  let carried = 0;
  const member = await sendRequest("/members/1");               carried += countLeaves(member);
  const loans = await sendRequest("/members/1/loans");          carried += countLeaves(loans);
  for (const l of loans) carried += countLeaves(await sendRequest(`/books/${l.isbn}`));
  return carried;
};

// --- Scenario 1: member name + book title and due date for each loan (7 fields) ---
console.log("-- scenario 1: member name, book title, due date --");
report("resource-based", await resourceBased(), 7);
reset();
const rpc = await sendRequest("/rpc", { method: "memberLoanSummary", params: { memberId: 1 } });
report("remote procedure", countLeaves(rpc), 7, JSON.stringify(rpc.result).slice(0, 78) + " ...");
reset();
const result = await sendRequest("/query", { query: "member(1) { name loans { dueDate book { title } } }" });
report("query-based", countLeaves(result), 7, JSON.stringify(result.data).slice(0, 78) + " ...");

// --- Scenario 2: the client's requirement changed, the author's name is now wanted too (10 fields) ---
console.log("\n-- scenario 2: author name added to the response --");
report("resource-based", await resourceBased(), 10, "server did not change: author was already carried");
reset();
const rpc2 = await sendRequest("/rpc", { method: "memberLoanSummaryWithAuthors", params: { memberId: 1 } });
report("remote procedure", 0, 10, `server response: ${JSON.stringify(rpc2)} -> new method needed`);
reset();
const result2 = await sendRequest("/query",
  { query: "member(1) { name loans { dueDate book { title author } } }" });
report("query-based", countLeaves(result2), 10, "server did not change: author field added to the query");
agent.destroy();
```

```bash
node server.mjs & p=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:8451/members/1
node measure.mjs
kill $p
```

```
three styles 127.0.0.1:8451
-- scenario 1: member name, book title, due date --
resource-based    requests= 5  sent= 404 B  received=1702 B  carried fields=47  used=  %15
remote procedure  requests= 1  sent= 174 B  received= 379 B  carried fields= 7  used= %100
                  {"name":"Alice Kane","loans":[{"dueDate":"2024-05-15","bookTitle":"Introductio ...
query-based       requests= 1  sent= 185 B  received= 403 B  carried fields= 7  used= %100
                  {"member":{"name":"Alice Kane","loans":[{"dueDate":"2024-05-15","book":{"title ...

-- scenario 2: author name added to the response --
resource-based    requests= 5  sent= 404 B  received=1702 B  carried fields=47  used=  %21
                  server did not change: author was already carried
remote procedure  requests= 1  sent= 185 B  received= 159 B  carried fields= 0  used=    -
                  server response: {"error":"unknown method"} -> new method needed
query-based       requests= 1  sent= 192 B  received= 459 B  carried fields=10  used= %100
                  server did not change: author field added to the query
```

Byte counts also include headers, so they depend on the runtime's default header set;
field counts and request counts, however, depend on the data and are independent of the
machine.

## What the Measurement Says

**Under-fetching** shows up in the resource-based style as request count. The member
record is one request, the loan list is one request, then one book request per loan: five
in total. This number is not fixed; it depends on the number of loans. The pattern of
fetching a record list and making a separate request for each row in that list is known as
the **N+1 problem**, and the round-trip count grows as the data grows.

**Over-fetching** shows up in the same style as field count. Forty-seven fields were
carried, and seven were used. The member's address, phone, fine amount; the book's
summary, publisher, shelf number — none of these had been requested. In the
resource-based style, returning the whole record is not a defect but the definition of
that style: an address corresponds to a **record**, not a **view**.

The other two styles solve both problems in a single request: seven fields are carried,
seven are used. The difference between them shows up in the second scenario.

## Who Determines the Response Shape

In the second scenario, the requirement changed: the author's name is now wanted too. Each
of the three styles had to change from a different place.

**In the resource-based style, nothing changed.** The author's name was already carried;
the used share went from 15% to 21%, and that was all. This is the unexpected benefit of
over-fetching: a field the client needs later is, more often than not, already in its
hands.

**In the remote-procedure style, the server had to change.** The method the client called
does not exist, and the server reports this. To get the new field, either the existing
method's response is extended or a new method is added; either way, the client cannot
proceed until the server is republished. In this style, the response shape is the server's
decision, so a shape change is also the server's job.

**In the query-based style, only the client changed.** `author` was added to the selection
set, the server stayed the same, the carried fields went from seven to ten, and the used
share stayed at 100%. Because the client determines the response shape, a shape change is
also the client's job.

This is the real trade-off among the styles, and its cost lands on the server side. In a
query-based interface, the server must be able to serve every field combination the client
might request; how much data access an incoming query will trigger is not known in
advance. In the remote-procedure style, by contrast, the server knows exactly what each
response will bring, because it fixed the shape itself.

## What Style Choice Depends On

The measurement does not show that one style is better than another; it shows that every
measure has an owner.

If the number of consumers is small and their requirements resemble each other, the server
fixing the shape produces the fewest surprises. If there are many consumers and each wants
a different field combination, leaving the shape to the client spares the server from
changing for every new screen. If the consumer side relies on caching, every record having
its own address turns into an advantage: the same book requested a second time never goes
out to the network. This last point is the subject of the next lesson.

There is also one item that does not enter the measurement: **understandability**. In a
resource-based interface, addresses are concept names; someone unfamiliar with the system
can learn the domain by looking at the address list. In single-address styles, this
information lives inside the body and can only be seen by reading the contract.

## Summary

- All three styles meet the same job, but a different side determines the response shape:
  the record itself in the resource-based style, the server in the remote-procedure style,
  the client in the query-based style.
- In the resource-based style, the measurement gave five requests and forty-seven carried
  fields; seven fields were used. Under-fetching shows up in request count, over-fetching
  in the used share.
- A request count that grows with the number of loans is the N+1 pattern; it appears in
  any design that fetches a list and makes a separate request for each row.
- When the requirement changed, the server had to change in the remote-procedure style and
  the client in the query-based style; in the resource-based style, the excess carried data
  made the change unnecessary.
- The cost of the query-based style is on the server side: the data access an incoming
  query will trigger is not known in advance, whereas the cost of a fixed-shape response is
  known.

## Next Step

The resource-based style was the option that carried the most bytes and made the most
requests in the measurement, yet it is the most widely adopted style. This contradiction
comes from properties the measurement does not capture: every record having its own
address makes it possible for the same record, requested a second time, to never go out to
the network at all; every request being self-contained makes it unimportant which server
copy the request lands on. The next lesson names these properties as a set of constraints
and tests all three by running them: the same request giving the same response in two
separate processes, the concrete counterpart of a uniform interface, and the effect of
cacheability on bytes carried.
