Tags: web-dev concept

Backwards Compatibility

Date: 2026-08-17


Changing something other people depend on without breaking them. The half people forget is forward compatibility — old code reading new data — and during any rolling deploy you need both simultaneously.


Backwards compatible: new code works with old data and old callers. Forward compatible: old code tolerates new data.

BACKWARD   new reader ← old data
           needed on every deploy

FORWARD    old reader ← new data
           needed whenever a client is
           out of your control

Why you need both at once

Nothing deploys atomically:

ROLLING DEPLOY
  server A: new code
  server B: old code       ← for minutes
  both hitting one database

MOBILE APPS
  users on versions from three years ago
                           ← for years

CACHED JAVASCRIPT
  a browser holding yesterday's bundle
  calling today's API

QUEUED MESSAGES
  written by old code, read by new

During any deploy, old and new code coexist. A change that’s backwards compatible but not forwards compatible breaks the old instances that are still running — which presents as intermittent errors that stop on their own, and gets dismissed as a blip.

What counts as breaking

Wider than most people assume:

CLEARLY BREAKING
  removing a field or endpoint
  renaming anything
  changing a type
  adding a required field
  tightening validation

BREAKING IN PRACTICE
  changing a default
  changing the ORDER of a list
  adding a field that a strict client
    rejects
  changing an error code
  making a sync operation async
  changing precision or rounding
  changing what NULL means

Adding a field can break a strict consumer, which is why “ignore unknown fields” is the single most valuable convention in schema design — it makes additive change safe by default — Serialisation Formats.

The expand–contract pattern

The technique for making any breaking change safe. It costs three deploys instead of one:

1  EXPAND
   add the new thing alongside the old
   write to BOTH, read from the old

2  MIGRATE
   backfill; switch readers to the new
   both still work

3  CONTRACT
   stop writing the old; verify nothing
   reads it; remove

Applied to a column rename:

1  add `email_address`; write both
2  backfill; switch reads
3  drop `email`

Each step is independently deployable and reversible. A single-step rename is not — Database Migrations.

Deprecation that works

1  ANNOUNCE — with a replacement named
   and a date
2  WARN — response header, console
   warning, or a log line
3  MEASURE — who is still calling it
4  CONTACT the remaining callers
5  REMOVE

  ← never skip 3. Removing something
    without knowing who uses it is a
    guess

A deprecation warning must name the replacement. “This endpoint is deprecated” costs the reader a search; “use /v2/orders, see the migration guide” doesn’t — Design System Versioning.

Designing for it upfront

  • Additive by default. Add fields, don’t repurpose them
  • Never reuse a name for a different meaning. A field that meant pence and now means pounds is the worst kind of break: silent, and wrong by 100×
  • Ignore unknown fields on every consumer you write
  • Version at the boundary, not internally — Versioning
  • Make new fields optional with a sensible default
  • Keep enums open. A consumer that crashes on an unrecognised status value blocks you from ever adding one

The judgement

Every compatibility guarantee is a constraint you accept forever. Supporting an API version indefinitely means carrying its assumptions into every future design.

The realistic position: strong guarantees at public boundaries, weaker ones internally. A public API needs long support; a service consumed only by your own front end, deployed together, needs much less — and treating both with the same ceremony is a common source of unnecessary work.