---
title: 'Deprecation Policy'
source: 'https://academia.sh/en/courses/api-design/deprecation-policy'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:27+00:00'
license: 'CC BY-SA 4.0'
---

# Deprecation Policy

Announcing deprecation in the response itself, collecting per-consumer version telemetry, and calculating the consumer count and traffic share remaining on the old version at the end of the transition period.

The differ detected seven breaking changes and raised the version to 2.0.0. Raising the
version number does not migrate old clients on its own; v1 still works, and every client
that calls it keeps working. Maintaining v1 forever is not an option for the library
either: two versions means every bug fix has to be done twice and every new field has to
be produced in two shapes.

The path between the two is **deprecation**: the version is first declared deprecated,
keeps running for a while longer, and is then closed. This lesson establishes three
things — where the announcement is written, how to measure who is still on the old
version, and which number the sunset date is chosen by looking at.

## Writing the Announcement Into the Response

If the announcement is written into the documentation, only those who read the
documentation see it. The party the announcement needs to reach is the **code that calls
the old version**; the person who wrote that code cannot be expected to read the
documentation that exact week. That is why the announcement is put into the response
itself.

Two headers define this. `Deprecation` reports the moment the resource was deprecated;
`Sunset` reports the moment access will end. The two are separate information: the first
says "do not use this anymore," the second says "this will stop working on this date."
The `Link` headers placed alongside them point to the replacement resource and the
migration guide; an announcement that only says "this is going away" speeds up no
migration.

```js
// server.mjs — serves the v1 resource with a deprecation announcement
// Usage: node server.mjs <today>   example: node server.mjs 2026-04-10
import { createServer } from "node:http";

const TODAY = new Date(process.argv[2] ?? "2026-04-10");
const ANNOUNCED = new Date("2026-03-01T00:00:00Z");   // the date deprecation was announced
const SUNSET = new Date("2026-09-01T00:00:00Z");      // the date access will end

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

// Telemetry: which consumer called which version how many times.
const counts = new Map();   // "consumer|version" -> request count

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

  if (path === "/telemetry") {
    res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
    return res.end(JSON.stringify([...counts].map(([a, s]) => ({ consumer: a.split("|")[0], version: a.split("|")[1], requests: s }))));
  }

  const version = /^\/v(\d+)\//.exec(path)?.[1];
  const consumer = req.headers["api-consumer"] ?? "unknown";
  if (version) counts.set(`${consumer}|v${version}`, (counts.get(`${consumer}|v${version}`) ?? 0) + 1);

  if (path === "/v2/loans/O-1") {
    res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
    return res.end(JSON.stringify({ id: RECORD.id, member: { id: RECORD.member }, items: [{ isbn: RECORD.isbn }], dueDate: RECORD.returnDate }));
  }

  if (path === "/v1/loans/O-1") {
    // If the sunset date has passed, the resource no longer exists: 410, a permanent end.
    if (TODAY >= SUNSET) {
      res.writeHead(410, { "content-type": "application/problem+json; charset=utf-8", link: '</v2/loans/O-1>; rel="successor-version"' });
      return res.end(JSON.stringify({
        type: "https://example.library/problems/version-sunset", title: "Version sunset", status: 410,
        detail: `v1 was sunset on ${SUNSET.toUTCString()}; use /v2/loans.`, instance: "oc-0001",
      }));
    }
    // Announcement: Deprecation gives the moment of deprecation, Sunset the moment access ends.
    res.writeHead(200, {
      "content-type": "application/json; charset=utf-8",
      deprecation: `@${Math.floor(ANNOUNCED.getTime() / 1000)}`,
      sunset: SUNSET.toUTCString(),
      link: '</v2/loans/O-1>; rel="successor-version", </docs/migrate-v2>; rel="deprecation"',
    });
    return res.end(JSON.stringify(RECORD));
  }

  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-0002" }));
}).listen(8436, "127.0.0.1", () => console.log(`server 127.0.0.1:8436 today=${TODAY.toISOString().slice(0, 10)}`));
```

```bash
#!/usr/bin/env bash
# Same request before and after the sunset date; then a telemetry dump.
for today in 2026-04-10 2026-09-02; do
  node server.mjs "$today" & s=$!
  sleep 0.5
  echo "--- $today ---"
  curl -sS -D - -o /tmp/g -H 'Api-Consumer: mobile-app' http://127.0.0.1:8436/v1/loans/O-1 \
    | grep -iE '^HTTP|^deprecation|^sunset|^link' | tr -d '\r'
  cat /tmp/g; echo
  if [ "$today" = "2026-04-10" ]; then
    curl -sS -o /dev/null -H 'Api-Consumer: shelf-terminal' http://127.0.0.1:8436/v1/loans/O-1
    curl -sS -o /dev/null -H 'Api-Consumer: mobile-app' http://127.0.0.1:8436/v2/loans/O-1
    echo "telemetry: $(curl -sS http://127.0.0.1:8436/telemetry)"
  fi
  kill "$s"; wait "$s" 2>/dev/null
done
```

```
server 127.0.0.1:8436 today=2026-04-10
--- 2026-04-10 ---
HTTP/1.1 200 OK
deprecation: @1772323200
sunset: Tue, 01 Sep 2026 00:00:00 GMT
link: </v2/loans/O-1>; rel="successor-version", </docs/migrate-v2>; rel="deprecation"
{"id":"O-1","member":"U-1001","isbn":"978-0262033848","returnDate":"2026-03-20"}
telemetry: [{"consumer":"mobile-app","version":"v1","requests":1},{"consumer":"shelf-terminal","version":"v1","requests":1},{"consumer":"mobile-app","version":"v2","requests":1}]
server 127.0.0.1:8436 today=2026-09-02
--- 2026-09-02 ---
HTTP/1.1 410 Gone
link: </v2/loans/O-1>; rel="successor-version"
{"type":"https://example.library/problems/version-sunset","title":"Version sunset","status":410,"detail":"v1 was sunset on Tue, 01 Sep 2026 00:00:00 GMT; use /v2/loans.","instance":"oc-0001"}
```

Three details matter. The announcement headers arrive together with a **successful**
response; the request has been fulfilled, only a warning accompanies it. The code returned
after the sunset is not 404 but **410**: 404 says "there is nothing here," 410 says "there
was something here and it was permanently removed"; the second prevents the client from
thinking it built the wrong address. And the `Link` header stays in the 410 response too,
because the moment of sunset is exactly when migration information is needed most.

The telemetry line is the third part. The server counts every request by consumer identity
and version; without this counter, the sunset date is chosen by guesswork. The consumer
identity arrives here through a header; once authentication is in place, this information
is read from the token and does not depend on the client reporting it.

## What Remains at the End of the Transition Period

Telemetry gives how many consumers migrate at what speed. This speed varies by segment:
actively developed large consumers migrate fast, small consumers untouched for years
migrate slowly. The program below calculates what remains at the end of the transition
period for three announcement styles.

```js
// transition.mjs — computes the consumer count and traffic share remaining on the old version at the end of the transition period
// Model: each week, a fixed fraction of a segment's remaining consumers migrates.

const SEGMENTS = [
  { name: "large",  count: 4,   traffic: 0.60 },
  { name: "medium", count: 20,  traffic: 0.30 },
  { name: "small",  count: 176, traffic: 0.10 },
];

// Weekly migration rate: varies by how the announcement was made.
const ANNOUNCEMENTS = {
  "docs only":                 { large: 0.05, medium: 0.03, small: 0.01 },
  "response header":           { large: 0.15, medium: 0.10, small: 0.04 },
  "header + direct warning":   { large: 0.35, medium: 0.10, small: 0.04 },
};

const WEEKS = 26;   // between March 1 and September 1

const remaining = (rate, week) => (1 - rate) ** week;

console.log(`transition period: ${WEEKS} weeks   total consumers: ${SEGMENTS.reduce((t, k) => t + k.count, 0)}\n`);
console.log("announcement style          remaining consumers  remaining traffic  large  medium  small");
for (const [name, rates] of Object.entries(ANNOUNCEMENTS)) {
  const fractions = SEGMENTS.map((k) => remaining(rates[k.name], WEEKS));
  const consumers = SEGMENTS.reduce((t, k, i) => t + k.count * fractions[i], 0);
  const traffic = SEGMENTS.reduce((t, k, i) => t + k.traffic * fractions[i], 0);
  console.log(
    `${name.padEnd(28)} ${Math.round(consumers).toString().padStart(19)}  ${(traffic * 100).toFixed(1).padStart(17)}%  ` +
    fractions.map((o, i) => Math.round(SEGMENTS[i].count * o).toString().padStart(5)).join(" ")
  );
}

// If the sunset criterion is traffic share: how much does the window need to stretch?
const THRESHOLD = 0.005;
console.log(`\nweeks needed until remaining traffic drops below ${THRESHOLD * 100}%:`);
for (const [name, rates] of Object.entries(ANNOUNCEMENTS)) {
  let h = 0;
  while (SEGMENTS.reduce((t, k) => t + k.traffic * remaining(rates[k.name], h), 0) > THRESHOLD && h < 500) h++;
  console.log(`  ${name.padEnd(28)} ${String(h).padStart(3)} weeks (${(h / 4.35).toFixed(1)} months)`);
}

// Per segment: which segment the remaining traffic comes from
console.log("\ndistribution of remaining traffic across segments under 'response header' announcement:");
const o = ANNOUNCEMENTS["response header"];
const remainingTraffic = SEGMENTS.map((k) => k.traffic * remaining(o[k.name], WEEKS));
const total = remainingTraffic.reduce((a, b) => a + b, 0);
SEGMENTS.forEach((k, i) =>
  console.log(`  ${k.name.padEnd(7)} ${(remainingTraffic[i] * 100).toFixed(2)}%  (${((remainingTraffic[i] / total) * 100).toFixed(0)}% of remaining traffic)`));
```

```
transition period: 26 weeks   total consumers: 200

announcement style          remaining consumers  remaining traffic  large  medium  small
docs only                                    146               37.1%      1     9   136
response header                               62                6.3%      0     1    61
header + direct warning                       62                5.4%      0     1    61

weeks needed until remaining traffic drops below 0.5%:
  docs only                    299 weeks (68.7 months)
  response header               75 weeks (17.2 months)
  header + direct warning       74 weeks (17.0 months)

distribution of remaining traffic across segments under 'response header' announcement:
  large   0.88%  (14% of remaining traffic)
  medium  1.94%  (31% of remaining traffic)
  small   3.46%  (55% of remaining traffic)
```

The table gives three results.

First, the effect of writing the announcement into the response is large: when announced
only in the documentation, thirty-seven percent of traffic is still on the old version at
the end of six months, while when announced through the response header, this share drops
to 6.3 percent. The difference is not a policy change but a change in where the
announcement reaches.

Second, reaching out to large consumers directly brings total traffic down from 6.3
percent to only 5.4 percent. Direct warning noticeably speeds up large consumers'
migration, but their share has already evaporated within six months; fifty-five percent of
remaining traffic comes from small consumers, and the cost of reaching them one by one is
high. **The way to reduce remaining traffic does not run through talking to the largest
consumers.**

Third and most important, the two numbers separate from each other. Under the
response-header announcement, **62 consumers** are still on the old version at the end of
the transition period, but they produce only 6.3 percent of traffic. The answer to "how
many consumers are left" postpones the sunset; the answer to "how much traffic is left"
makes the sunset possible. The two are two measures of the same reality, and the decision
changes depending on which one is looked at.

## Choosing the Sunset Date

The last table shows the consequence of setting the criterion as a threshold: bringing
remaining traffic below 0.5 percent takes 75 weeks — seventeen months — under the
response-header announcement. A six-month window cannot hit this threshold.

This gives three options, and all three are legitimate. The window is extended to
seventeen months, and the two versions live together that long. The threshold is relaxed;
6.3 percent is deemed acceptable, and the remaining consumers get a 410 at sunset. Or the
sunset is staged: access is cut off at specific hours first, then closed entirely — so
consumers who have not migrated notice the sunset in production, but are not caught
unprepared at the moment of full closure.

What determines the choice is **who** the remaining consumers are. If the remaining 6.3
percent is the library's own shelf terminals, the window is kept short, because the party
that will carry out the migration is also the library. If it is outside district
libraries' software, the window is extended. This is exactly why telemetry is collected
per consumer: the total share is not enough to make this decision.

It is also useful for the window to be measured by **release**, not by calendar: the
phrase "for one major version" keeps its meaning even if release speed changes. But if the
sunset moment is to be reported in the response, it has to be converted into a date; the
`Sunset` header carries a date, not a release number. The two are used together: the
policy is written in releases, the announcement is converted to a date.

## Summary

- The place the announcement needs to reach is not the documentation but the code that
  calls the old version; that is why the `Deprecation` and `Sunset` headers are sent
  together with the successful response.
- `Deprecation` reports the moment of deprecation, `Sunset` the moment access will end;
  the `Link` headers alongside them point to the replacement resource and the migration
  guide.
- After the sunset, 410 is returned instead of 404, and the migration link stays in the
  410 response too.
- Writing the announcement into the response brings traffic remaining on the old version
  at the end of six months down from 37 percent to 6.3 percent.
- More than half of remaining traffic comes from small consumers; reaching out to the
  largest consumers directly reduces total remaining traffic by only 0.9 percentage
  points.
- The remaining consumer count and the remaining traffic share are separate measures, and
  the sunset decision changes depending on which one is looked at; telemetry is collected
  per consumer precisely to be able to make this distinction.

## Next Step

Everything built up to this point — error format, field-level validation, versions,
breakingness rules, the deprecation window — is part of the contract, and all of it has
been described in prose so far. Prose has two problems as a contract: it cannot be handed
to a machine, and it cannot be compared against reality. The differ was working on a
schema; where does that schema come from, and how is it known that it matches what the
server actually produces? The next lesson turns the contract into a machine-readable
definition, produces a validator from that definition, tests real requests, and catches
the case where the schema and the real response diverge.
