Back to System Design
System Design
easy
mid

How do you maintain backward compatibility for a published JavaScript package?

Strict semver: breaking change → major. Use Changesets for changelog discipline. Mark APIs experimental with `@experimental` JSDoc. Deprecate with console.warn (or peer-dep typing) for one minor cycle before removing. Ship codemods for non-trivial migrations. Maintain release branches for the last 1–2 majors. Type-test with `tsd` / `expect-type` to catch unintended type changes.

4 min read·~12 min to think through

Public packages have an implicit contract with consumers: upgrades shouldn't break them in a minor or patch. Honoring that contract is a discipline, not magic.

Semver, strictly

ChangeBump
Bug fix, no API changePatch (1.2.3 → 1.2.4)
New API, existing intactMinor (1.2.3 → 1.3.0)
Breaking changeMajor (1.2.3 → 2.0.0)

"Breaking" includes types. Adding a required parameter, removing an export, narrowing a return type, renaming a prop — all major.

Changelog discipline

Use Changesets (@changesets/cli):

  • Every PR with a user-visible change adds a changeset file.
  • Bumps + changelog auto-generated.
  • Forces contributors to articulate the change at PR time.

Deprecation flow

Never delete an API in the same release you stop loving it:

  1. Deprecate. @deprecated JSDoc, console.warn first call ("X will be removed in v3, use Y").
  2. Coexist for at least one minor cycle (3+ months for active projects).
  3. Remove in the next major. Document migration in changelog + migration guide.

Codemods

For non-trivial migrations:

bash
npx @org/package-codemods rename-component-prop

jscodeshift or ts-morph for the heavy lifting. Time invested here pays back enormously — consumers upgrade willingly when the migration is one command.

Type stability

Public types are part of the API. Use tsd or expect-type tests:

ts
expectType<string>(myFn("a"));
expectAssignable<Props>({ x: 1 });

Catches accidental type-narrowings that break consumers.

Experimental APIs

Mark with @experimental JSDoc or prefix unstable_. Document they may break in minor versions. Once stable, promote without underscore.

Multiple release lines

For widely-used libraries (React, Vue), maintain branches:

  • main — next major.
  • v17.x — security patches for the last major.

npm publish --tag latest for current, --tag v17 for legacy.

Migration guides

Every major: MIGRATING.md per release. List every breaking change, the rationale, and the replacement. Link the codemod.

Dependencies

  • Peer deps for plugin-style packages so consumers pin React/Vue version.
  • Exact dep ranges vs caret — match your own update appetite.

Anti-patterns

  • "Soft breaking" changes in minors ("it's only the unusual case").
  • No deprecation period — surprise removals.
  • Renaming the package on a major (breaks even strict semver consumers).
  • Different changelog per maintainer (no consistency).

Interview framing

"Strict semver: any breaking change — runtime, types, or behavior — is a major. Use Changesets for changelog discipline so every PR articulates its impact. Deprecate APIs with @deprecated + console.warn for one minor cycle before removing them in the next major. Ship codemods for non-trivial migrations so upgrades are a single command. Pin types with tsd so type signatures don't drift silently. Maintain prior major release branches with security patches. Write a migration guide per major."

Follow-up questions

  • When is a type change technically not breaking?
  • How do you handle a dependency's breaking change?
  • What's a good deprecation window?

Common mistakes

  • Breaking change in a minor 'because it's a small case'.
  • No codemods → consumers stuck on old majors.
  • Removing APIs in same release as deprecating them.
  • Types drift in patch versions.

Performance considerations

  • Releases themselves cheap. Cost of breaking minor: many consumers pinned at old versions, defeats the upgrade cycle.

Edge cases

  • ESM/CJS dual publish — careful with subpath exports.
  • Native dependencies that need rebuilds.
  • Breaking behavior under a flag for opt-in.

Real-world examples

  • React's deprecation cycles, Next.js codemods, TypeScript's release rhythm, Vue 2→3 migration tooling.

Senior engineer discussion

Seniors treat backward compat as a feature with cost. They invest in codemods, write migration guides, and balance velocity vs stability for the consumer base they have.

Related questions