---
title: 'Versioning Strategies'
source: 'https://academia.sh/en/courses/api-design/versioning-strategies'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:28+00:00'
license: 'CC BY-SA 4.0'
---

# Versioning Strategies

Publishing two versions of the same resource on a single server, the measured equal cost to client code of path-based, header-based, and content-based versioning, and the real difference that comes from whether intermediaries can tell versions apart.

The validation schema was taken as fixed in the previous lesson: at most three items, the
return date at most thirty days ahead. These numbers can change; more importantly, the
body's **structure** can change. The loan record's `member` field turns from a plain
identifier string into an object when a name is also needed, the single `isbn` field turns
into an array when loans with multiple items are required, and the `returnDate` field is
renamed `dueDate`.

What these changes have in common is that they break clients that are already working.
Versioning does not prevent breakage; it keeps the **old contract in effect**. This lesson
runs three versioning styles side by side on the same server and measures two things: how
many lines of client code change, and whether intermediaries can tell the versions apart.

## Two Versions of the Same Resource

The server below serves a single loan record in two versions. The versions are derived from
the same data; only the shape of the exported body differs. All three styles run in the
same process.

```js
// server.mjs — publishes the same loan record in two versions, three separate versioning styles
import { createServer } from "node:http";

const RECORD = { id: "O-1", member: "U-1001", name: "Alice Carter", isbn: "978-0262033848", dueDate: "2026-03-20" };

// v1: flat shape, single book.  v2: member object, item array, field renamed.
const VERSIONS = {
  1: (k) => ({ id: k.id, member: k.member, isbn: k.isbn, returnDate: k.dueDate }),
  2: (k) => ({ id: k.id, member: { id: k.member, name: k.name }, items: [{ isbn: k.isbn }], dueDate: k.dueDate }),
};

const MEDIA_TYPE = "application/vnd.library.loan+json";

// Extracts the version parameter from the Accept header: ...+json;version=2
const acceptVersion = (accept) => {
  const m = /version=(\d+)/.exec(accept ?? "");
  return m ? Number(m[1]) : null;
};

const write = (res, version, extraHeaders) => {
  res.writeHead(200, { "content-type": `${MEDIA_TYPE}; version=${version}`, ...extraHeaders });
  res.end(JSON.stringify(VERSIONS[version](RECORD)));
};

createServer((req, res) => {
  res.sendDate = false;
  const path = req.url.split("?")[0];

  // 1) Path-based: version is part of the address. Two versions are two separate resource addresses.
  const m = /^\/v(\d+)\/loans\/(.+)$/.exec(path);
  if (m && VERSIONS[m[1]]) return write(res, Number(m[1]), {});

  if (path === "/loans/O-1") {
    // 2) Content-based: version is a parameter of the media type. Requires Vary: Accept.
    const accepted = acceptVersion(req.headers.accept);
    if (accepted && VERSIONS[accepted]) return write(res, accepted, { vary: "Accept" });

    // 3) Header-based: version is in a separate header. Vary: that header.
    const header = Number(req.headers["api-version"] ?? 1);
    if (VERSIONS[header]) return write(res, header, { vary: "Api-Version" });
  }

  res.writeHead(404, { "content-type": "application/problem+json; charset=utf-8" });
  res.end(JSON.stringify({ type: "https://example.library/problems/resource-not-found", title: "Resource not found", status: 404, detail: `${path} does not exist.`, instance: "oc-0001" }));
}).listen(8434, "127.0.0.1", () => console.log("server 127.0.0.1:8434"));
```

```bash
#!/usr/bin/env bash
# Requests the same record's two versions with three separate versioning styles.
node server.mjs & server=$!
sleep 0.5

echo "--- path-based ---"
curl -sS -w '  [%{content_type}]\n' http://127.0.0.1:8434/v1/loans/O-1
curl -sS -w '  [%{content_type}]\n' http://127.0.0.1:8434/v2/loans/O-1

echo "--- header-based ---"
curl -sS -H 'Api-Version: 1' -w '  [%{content_type}]\n' http://127.0.0.1:8434/loans/O-1
curl -sS -H 'Api-Version: 2' -w '  [%{content_type}]\n' http://127.0.0.1:8434/loans/O-1

echo "--- content-based ---"
curl -sS -H 'Accept: application/vnd.library.loan+json;version=1' -w '  [%{content_type}]\n' http://127.0.0.1:8434/loans/O-1
curl -sS -H 'Accept: application/vnd.library.loan+json;version=2' -w '  [%{content_type}]\n' http://127.0.0.1:8434/loans/O-1

kill "$server"; wait "$server" 2>/dev/null
```

```
server 127.0.0.1:8434
--- path-based ---
{"id":"O-1","member":"U-1001","isbn":"978-0262033848","returnDate":"2026-03-20"}  [application/vnd.library.loan+json; version=1]
{"id":"O-1","member":{"id":"U-1001","name":"Alice Carter"},"items":[{"isbn":"978-0262033848"}],"dueDate":"2026-03-20"}  [application/vnd.library.loan+json; version=2]
--- header-based ---
{"id":"O-1","member":"U-1001","isbn":"978-0262033848","returnDate":"2026-03-20"}  [application/vnd.library.loan+json; version=1]
{"id":"O-1","member":{"id":"U-1001","name":"Alice Carter"},"items":[{"isbn":"978-0262033848"}],"dueDate":"2026-03-20"}  [application/vnd.library.loan+json; version=2]
--- content-based ---
{"id":"O-1","member":"U-1001","isbn":"978-0262033848","returnDate":"2026-03-20"}  [application/vnd.library.loan+json; version=1]
{"id":"O-1","member":{"id":"U-1001","name":"Alice Carter"},"items":[{"isbn":"978-0262033848"}],"dueDate":"2026-03-20"}  [application/vnd.library.loan+json; version=2]
```

All three styles produce the same two bodies. The difference is where the version is
written: to the address in the path-based style, to a separate request-specific header in
the header-based style, to a parameter of the media type in the `Accept` header in the
content-based style.

The content-based style uses HTTP's own content negotiation mechanism. The `Content-Type`
the server returns also carries the version, so the response reports its own version. In
the header-based style, this information is in a separate header, and it is not a concept
HTTP knows about; for it to work, the client and the server must agree on the same header
name.

## The Cost to Client Code

The first justification for choosing a style is usually presented as "the client changes
less code." This is a measurable claim. The program below generates a client's source with
three call sites for each of the three styles, and counts the number of changed lines in
two scenarios: all call sites migrating to v2, and only one migrating.

```js
// client-cost.mjs — changed line count in client code for three versioning styles
// Three call sites, three scenarios: [1,1,1] all v1 | [2,2,2] full migration | [2,1,1] partial migration
// Constant names stay stable: the majority version keeps the main constant, the minority gets an added constant.
const MEDIA_TYPE = "application/vnd.library.loan+json";
const CALL_SITES = [["getLoan  ", "(id)", "/loans/${id}"], ["listLoans", "()  ", "/loans"], ["getMember", "(id)", "/members/${id}"]];
const majority = (s) => (s.filter((v) => v === 1).length >= 2 ? 1 : 2);
const call = (k, address, extra) => `export const ${k[0]} = ${k[1]} => request(\`${address}\`${extra});`;

const STYLES = {
  "path-based": (s) => {
    const c = majority(s), name = (v) => (v === c ? "BASE" : `BASE_V${v}`);
    return [
      `const BASE = "http://127.0.0.1:8434/v${c}";`,
      ...[...new Set(s)].filter((v) => v !== c).map((v) => `const BASE_V${v} = "http://127.0.0.1:8434/v${v}";`),
      ...CALL_SITES.map((k, i) => call(k, "${" + name(s[i]) + "}" + k[2], "")),
    ];
  },
  "header-based": (s) => {
    const c = majority(s), name = (v) => (v === c ? "VERSION" : `VERSION_V${v}`);
    return [
      'const BASE = "http://127.0.0.1:8434";',
      `const VERSION = { "api-version": "${c}" };`,
      ...[...new Set(s)].filter((v) => v !== c).map((v) => `const VERSION_V${v} = { "api-version": "${v}" };`),
      ...CALL_SITES.map((k, i) => call(k, "${BASE}" + k[2], `, ${name(s[i])}`)),
    ];
  },
  "content-based": (s) => {
    const c = majority(s), name = (v) => (v === c ? "VERSION" : `VERSION_V${v}`);
    return [
      'const BASE = "http://127.0.0.1:8434";',
      `const VERSION = { accept: "${MEDIA_TYPE};version=${c}" };`,
      ...[...new Set(s)].filter((v) => v !== c).map((v) => `const VERSION_V${v} = { accept: "${MEDIA_TYPE};version=${v}" };`),
      ...CALL_SITES.map((k, i) => call(k, "${BASE}" + k[2], `, ${name(s[i])}`)),
    ];
  },
};

const changed = (before, after) => after.filter((x) => !before.includes(x)).length;

console.log("style           lines  full migration  partial migration");
for (const [name, f] of Object.entries(STYLES)) {
  const v1 = f([1, 1, 1]);
  console.log(`${name.padEnd(15)} ${String(v1.length).padStart(5)} ` +
    `${String(changed(v1, f([2, 2, 2]))).padStart(15)} ${String(changed(v1, f([2, 1, 1]))).padStart(18)}`);
}

for (const name of ["path-based", "header-based"]) {
  console.log(`\n— ${name} client during partial migration —`);
  for (const s of STYLES[name]([2, 1, 1])) console.log(s);
}
```

```
style           lines  full migration  partial migration
path-based          4               1                  2
header-based        5               1                  2
content-based       5               1                  2

— path-based client during partial migration —
const BASE = "http://127.0.0.1:8434/v1";
const BASE_V2 = "http://127.0.0.1:8434/v2";
export const getLoan   = (id) => request(`${BASE_V2}/loans/${id}`);
export const listLoans = ()   => request(`${BASE}/loans`);
export const getMember = (id) => request(`${BASE}/members/${id}`);

— header-based client during partial migration —
const BASE = "http://127.0.0.1:8434";
const VERSION = { "api-version": "1" };
const VERSION_V2 = { "api-version": "2" };
export const getLoan   = (id) => request(`${BASE}/loans/${id}`, VERSION_V2);
export const listLoans = ()   => request(`${BASE}/loans`, VERSION);
export const getMember = (id) => request(`${BASE}/members/${id}`, VERSION);
```

All three styles give the same numbers: one line in a full migration, two in a partial
migration. This was not the expected result; it is a common belief that path-based
versioning is more expensive. The belief's source is the assumption that the address is
scattered across call sites. When the address is collected into a single constant, the
version is collected into that constant too, and the cost equalizes with the header-based
style.

What the measurement actually shows is this: **the cost is however many places the version
information sits in the client, not the style.** If it can sit in a single place in all
three styles, all three are the same price. In that case, the choice has to rest on a
justification outside client code.

## What Intermediaries See

That justification lies in the intermediaries the request passes through on the path
between client and server. The program below is a small cache and runs in two modes: a
mode that produces the key from the address alone, and a mode that also folds the request
headers declared in the response's `Vary` header into the key.

```js
// cache.mjs — a small cache that sits in front of the server
// Usage: node cache.mjs address   (key is the request address only)
//        node cache.mjs vary      (key is address + the headers named in the response's Vary)
import { createServer } from "node:http";

const MODE = process.argv[2] ?? "address";
const UPSTREAM = "http://127.0.0.1:8434";
const entries = new Map();     // key -> { body, type }
const varyInfo = new Map();    // address -> header names declared in the response's Vary

createServer(async (req, res) => {
  res.sendDate = false;
  const requestHeaders = { accept: req.headers.accept ?? "", "api-version": req.headers["api-version"] ?? "" };
  const keyFor = (names) => req.url + names.map((a) => `|${a}=${requestHeaders[a]}`).join("");

  const names = MODE === "vary" ? (varyInfo.get(req.url) ?? []) : [];
  const found = entries.get(keyFor(names));
  if (found) {
    res.writeHead(200, { "content-type": found.type, "x-cache": "hit" });
    return res.end(found.body);
  }

  const response = await fetch(UPSTREAM + req.url, { headers: requestHeaders });
  const body = await response.text();
  const type = response.headers.get("content-type");
  const vary = (response.headers.get("vary") ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);

  varyInfo.set(req.url, vary);
  entries.set(keyFor(MODE === "vary" ? vary : []), { body, type });

  res.writeHead(200, { "content-type": type, "x-cache": "miss" });
  res.end(body);
}).listen(8435, "127.0.0.1", () => console.log(`cache 127.0.0.1:8435 mode=${MODE}`));
```

```bash
#!/usr/bin/env bash
# Behind the cache: first the client requesting v2, then the client requesting v1.
node server.mjs & s=$!
sleep 0.4

ask() {   # ask <label> <curl-args...>
  printf '%-34s ' "$1"; shift
  curl -sS -D /tmp/b -o /tmp/g "$@"
  printf '%-9s version=%s  %s\n' \
    "$(grep -i '^x-cache' /tmp/b | tr -d '\r' | cut -d' ' -f2)" \
    "$(grep -io 'version=[0-9]' /tmp/b | head -1 | cut -d= -f2)" \
    "$(head -c 46 /tmp/g)"
}

for mode in address vary; do
  node cache.mjs "$mode" & o=$!
  sleep 0.4
  echo "== cache mode: $mode =="
  echo "-- header-based (single address) --"
  ask "client requesting v2" -H 'Api-Version: 2' http://127.0.0.1:8435/loans/O-1
  ask "client requesting v1" -H 'Api-Version: 1' http://127.0.0.1:8435/loans/O-1
  echo "-- path-based (two addresses) --"
  ask "client requesting v2" http://127.0.0.1:8435/v2/loans/O-1
  ask "client requesting v1" http://127.0.0.1:8435/v1/loans/O-1
  kill "$o"; wait "$o" 2>/dev/null
done

kill "$s"; wait "$s" 2>/dev/null
```

```
server 127.0.0.1:8434
cache 127.0.0.1:8435 mode=address
== cache mode: address ==
-- header-based (single address) --
client requesting v2               miss      version=2  {"id":"O-1","member":{"id":"U-1001","name":"Al
client requesting v1               hit       version=2  {"id":"O-1","member":{"id":"U-1001","name":"Al
-- path-based (two addresses) --
client requesting v2               miss      version=2  {"id":"O-1","member":{"id":"U-1001","name":"Al
client requesting v1               miss      version=1  {"id":"O-1","member":"U-1001","isbn":"978-0262
cache 127.0.0.1:8435 mode=vary
== cache mode: vary ==
-- header-based (single address) --
client requesting v2               miss      version=2  {"id":"O-1","member":{"id":"U-1001","name":"Al
client requesting v1               miss      version=1  {"id":"O-1","member":"U-1001","isbn":"978-0262
-- path-based (two addresses) --
client requesting v2               miss      version=2  {"id":"O-1","member":{"id":"U-1001","name":"Al
client requesting v1               miss      version=1  {"id":"O-1","member":"U-1001","isbn":"978-0262
```

The critical line is the second line under `address` mode. The client requesting
`Api-Version: 1` got a cache hit and was returned the **v2 body**: the `member` field is an
object instead of a plain identifier. Because the cache only looks at the address, it
counted the two requests as the same request and gave the second one the first request's
response. This is a silent inconsistency — the client does not get the structure it
expects, and **no error response is produced**; the status code is 200. In the path-based
style, the same scenario gives two separate misses; two addresses are two records, and
there is no way for them to mix.

In `vary` mode, the header-based style also works correctly. So the problem is not in the
style, but in whether the intermediary correctly processes the `Vary` header. But this is
what matters when deciding: header-based and content-based styles depend, for correctness,
on **every** intermediary along the path correctly processing the `Vary` header; the
path-based style expects nothing from any intermediary. The same justification holds for
logs, dashboards, and debugging: in the path-based style, which version was called how much
can be read from the address log; in the others, it cannot be read unless the header was
recorded.

On this dimension, the content-based style behaves the same as the header-based style, but
it has one advantage: the `Accept` header is a header HTTP recognizes, and it is ordinary
for caches to encounter `Vary: Accept`. A response that varies by a header name the service
made up itself is a less expected situation from the intermediaries' point of view.

## What Gets Versioned

None of the three styles answers the question of **what** the version attaches to. There
are two options. If the whole API is versioned, a single version number covers all
resources; a breaking change in one resource raises the version for all of them, and even
clients whose resource has not changed have to move to the new version. If versioning is
per resource, each resource moves at its own pace, but the number of versions the client
has to track becomes as many as the number of resources, and the links between responses
have to separately state which version they go to.

The choice looks at how tightly coupled the resources are to each other: if the loan record
is connected to the member and the book through embedded fields, versioning the three
separately leads to inconsistent combinations. The choice between a single package and a
package per component in the Design Systems course is the same trade-off's counterpart in
another domain.

## Summary

- Versioning does not prevent a breaking change; it keeps the old contract in effect,
  meaning two contracts live side by side for a while.
- Path-based, header-based, and content-based styles produce the same two bodies; the
  difference is whether the version is written to the address, to a separate header, or to
  a parameter of the media type.
- The cost to client code is the same in all three styles — one line in a full migration,
  two in a partial migration; what matters is not the style but how many places the version
  information sits in the client.
- The real difference is in the intermediaries: an address-keyed cache returns the v2 body
  to a client requesting v1 under header-based and content-based styles, and produces no
  error.
- Header-based and content-based styles depend, for correctness, on every intermediary
  along the path processing the `Vary` header; the path-based style expects nothing from
  any intermediary.
- Whether the version attaches to the API or to the resource is a separate decision, made
  according to how coupled the resources are to each other.

## Next Step

This lesson published two versions side by side but did not ask **why** the version number
went up to two: the `member` field turning into an object, the `isbn` field turning into an
array, and the `returnDate` field being renamed were accepted as breaking without
discussion. But is adding a new option to the `branch` field breaking? Making a field that
could be left blank required? Adding a new field to the response? When the answer to these
questions is left to intuition, every team gives a different answer and the version number
becomes a matter of debate. The next lesson writes a script that compares two schemas and
detects breaking changes, and derives the version number from the diff instead of from
argument.
