Compatibility matrix

Which API versions support which clients.

7 minhard

The web app updates instantly. Mobile takes a week to be on most users' devices. Desktop's a slow trickle. Your API has to support all three versions simultaneously. The compatibility matrix is how you keep track.

The problem in one paragraph

You ship the API at v1.0. Web is at v1.0. You add a field, deploy API + web to v1.1 together — fine, they updated in lockstep. Now mobile pushes a v1.1 build to TestFlight, takes 24h to review, another week for the user to update. During that week, the user is on mobile v1.0 + API v1.1. Did you BREAK them, or are they FINE?

The matrix — write it down

Maintain a real document. docs/compat-matrix.md at the repo root:

docs/compat-matrix.md
# Client / API compatibility
| API ver | Web ver | Mobile ver | Desktop ver | Notes |
|---------|----------|------------|-------------|------------------------|
| 1.0 | >= 1.0 | >= 1.0 | >= 1.0 | initial release |
| 1.1 | >= 1.0 | >= 1.0 | >= 1.0 | added optional field |
| 1.2 | >= 1.2 | >= 1.0 | >= 1.0 | web-only feature flag |
| 2.0 | >= 2.0 | >= 1.9 | >= 1.9 | BREAKING — see below |
## 2.0 breaking changes
- Removed deprecated /api/legacy-search
- Renamed Product.code → Product.sku (was deprecated since 1.5)
- Clients must send X-Client-Version header

Three things this gives you:

  • A single source of truth before deploys.
  • A way to refuse old clients (via X-Client-Version + middleware).
  • A historical record when debugging "why did v1.2 break for some users?"

Two API rules that make compat easy

1. Adding fields is fine. Removing them is breaking.

Old clients ignore new fields. So adding is_featured: bool to /api/products does not break mobile v1.0 — it just doesn't use the field. Removing or renaming a field breaks every client that reads it.

2. New endpoints are fine. Changed semantics are breaking.

A new POST /api/search endpoint costs nothing — old clients call the old /api/products?q=. But if you change what POST /api/products returns (now it's 202 instead of 201), old clients break.

Add then deprecate, don't mutate. When you need to rename a field: add the new field, populate both, wait for clients to migrate, then remove the old field. This is how you ship breaking changes without breaking anyone.

X-Client-Version — the runtime check

apps/web/lib/api.ts (and same in mobile/desktop)
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION // baked at build time
export async function api(path: string, opts?: RequestInit) {
return fetch(API_URL + path, {
...opts,
headers: {
...opts?.headers,
'X-Client-Version': APP_VERSION,
'X-Client-Surface': 'web', // or 'mobile' / 'desktop'
},
})
}
apps/api/internal/middleware/version_gate.go
func VersionGate(minVersions map[string]string) gin.HandlerFunc {
return func(c *gin.Context) {
surface := c.GetHeader("X-Client-Surface")
version := c.GetHeader("X-Client-Version")
min, ok := minVersions[surface]
if ok && semver.Compare("v"+version, "v"+min) < 0 {
c.AbortWithStatusJSON(426, gin.H{
"error": "client_too_old",
"min_version": min,
"upgrade_url": "https://app.example.com/upgrade",
})
return
}
c.Next()
}
}

The HTTP 426 ("Upgrade Required") is the standard status code for "your client is too old". Each surface handles it differently: web reloads; mobile shows a forced-update screen; desktop triggers the auto-updater.

Per-surface upgrade UX

  • Web: on 426, show a banner "a new version is available, refresh". The next page load gets the new JS. Easiest.
  • Desktop: on 426, trigger the auto-updater (from the Desktop course). User clicks "Update", app restarts in < 30s.
  • Mobile: hardest. You can show a forced screen with a link to the App Store, but if the app needs review, the user is stuck for hours. Plan ahead: keep mobile backwards-compat for at least 2-3 minor versions.

Real example — adding a required field

Marketing wants every product to have a category. How to roll it out without breaking mobile?

  1. API v1.5: Add the category field as optional. Default to empty string. Existing rows backfilled to empty.
  2. Web + admin v1.5: Adds a UI to set it on new products.
  3. Mobile v1.5: Reads it if present, hides the section if empty.
  4. API v1.6 (months later): Make it required server-side. By now all active products have a category.

Three releases over weeks. Slow, but no client ever 500'd.

Quick check

You ship API v2.0 with a breaking change. Mobile v1.0 users (still 30% of your install base) start hitting 500s. What's the FIRST thing you should do?

Try it

Set up a real compatibility gate:

  1. Create docs/compat-matrix.md in your repo with the current versions.
  2. Add X-Client-Version + X-Client-Surface to every API call from web / admin / mobile / desktop.
  3. Wire the VersionGate middleware in the API. Set the current min versions in a map.
  4. Hand-set web's version to v0.0.1 in .env. Hit the API. Should get a 426 + upgrade JSON.
  5. Implement the "new version available" banner on web that shows when a 426 comes back.

What's next

Final lesson — Staggered releases. With the compat matrix in hand, the actual deploy order: API first, then web, then desktop, then mobile (with reasoning at each step).

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub