
Anas IssathA tiny npm library shipped a "minor" release and broke some of the biggest JavaScript tools for three hours. What it teaches every backend engineer about Semantic Versioning.
Here is the entire job of a package called is-promise:
function isPromise(obj) {
return !!obj && (typeof obj === 'object' || typeof obj === 'function')
&& typeof obj.then === 'function';
}
One function. It checks whether something looks like a Promise.
That's it.
In April 2020, it was being downloaded around 12 million times a week. GitHub counted about 3.5 million projects depending on it, mostly without knowing it existed.
On Saturday, 25 April 2020, its maintainer published version 2.2.0.
Within hours, people trying to start a new project with create-react-app, angular-cli or firebase-tools were staring at an error instead.
The version number said minor. Nothing about the result was minor.
Semantic Versioning gives every release three numbers: MAJOR.MINOR.PATCH.
That promise is what makes automatic upgrades possible. When a project lists a dependency as "is-promise": "^2.1.0", the caret says: give me any 2.x version from 2.1.0 upward. Patches and minors flow in on their own, because SemVer says they're safe.
Python works the same way. requests~=2.31 or django>=5.0,<6.0 in your requirements are the same bet: anything below the next MAJOR shouldn't break you.
So when 2.2.0 appeared, thousands of dependency trees picked it up with nobody clicking anything.
That's the system working exactly as designed. It's also why a mislabelled release spreads so fast.
Version 2.2.0 was the first release of is-promise in about five years. The maintainer, Forbes Lindesay, wanted to modernise it: add support for ES Modules, JavaScript's newer import system, while keeping the old CommonJS require() working.
The changes were small and reasonable:
import isPromise from 'is-promise'.exports field in package.json, the newer way of telling Node which file to load for which module system.To the author, this was an addition. New way to import it, old way still there. That's the textbook definition of MINOR.
The problem was one line in package.json. The exports field pointed to "index.js" instead of the format Node expects, a path starting with ./. Node rejected it outright:
Error [ERR_INVALID_PACKAGE_TARGET]: Invalid "exports" main target "index.js"
defined in the package config
exports field by default. Older Node ignored it, which is probably why it worked on the author's machine.npx create-react-app. Tools run through npx install fresh, and a library's lockfile isn't used when someone else installs it. Every new project got 2.2.0.is-promise. It came in through a dependency of a dependency.The maintainer moved fast. Around three hours later, version 2.2.2 was out, and it simply dropped the ES Module support. The modern version eventually shipped properly, as a new MAJOR release: 4.0.0.
Here's the detail I find most interesting.
Node's own documentation on dual-module packages already said that adding exports and type to an existing package is a breaking change. Even when done perfectly, the exports field changes which files consumers are allowed to load. Anyone who imported a deep path like is-promise/index.js could break even with a correct config.
So the honest version number was 3.0.0, before the typo was even made.
The author labelled the release by intent: I'm adding a feature. SemVer asks about impact: what will your users notice?
Those are different questions, and they give different answers more often than we admit.
The question I use now, for libraries and for APIs, is:
"Can anyone using this observe the difference?"
Not "did I mean to break something." Not "is this technically a feature." Just whether someone on the other side could notice.
You don't need to publish npm packages to fall into this. Every backend API has consumers, and the same gap between intent and impact shows up in ordinary tickets:
username field. It feels like an addition. But a request that returned 200 yesterday returns 400 today. You removed something.Here's the decision tree I'd put in a pull request template:
Can any consumer observe the difference?
│
├── NO → PATCH (internal fixes, performance, logging)
│
└── YES → What kind of difference?
├── Removes, renames, or changes a type → MAJOR
├── Rejects input that used to be accepted → MAJOR
├── Changes how the package/API is loaded → MAJOR
├── Changes the meaning of an existing output → MAJOR, or MINOR with advance notice
└── Adds something optional → MINOR
The is-promise change sits on the third branch. It changed how the package was loaded, which is exactly why it needed a new MAJOR.
None of this means every release should be MAJOR.
A slow query made faster is a patch. An internal log nobody else can see is a patch. A crash fixed in a code path that never returned anything useful is a patch.
And inflating versions has its own cost. If every release is "breaking," consumers stop reading the number and pin everything forever. Then they miss your security fixes too.
The version only works as a signal if it's honest in both directions.
"It's only a small change" isn't evidence. "It works on my machine" definitely isn't.
The best part of this story is how it was handled.
Forbes Lindesay shipped a fix within hours and wrote a public post-mortem. He added end-to-end tests that install the package the way a real user would, then check it actually loads. And the ES Module support came back later as a proper major version instead of a minor.
It was an honest mistake that could have happened to almost anyone. What's worth copying is the response: test from the consumer's side, and let the version number tell the truth.
Semantic Versioning looks like it's about code. It's really a promise to the people who depend on you, written in three numbers.
is-promise didn't break because it was small, or because one-line packages are silly. It broke because the version described what the author meant to do, while millions of installs depended on what the release actually did.
Before you pick the next version number, ask one question:
"Can anyone tell the difference?"
If they can, the PATCH or MINOR has to justify itself.
What's the smallest release you've seen break something big? I'd like to hear the gray-zone stories other engineers have run into. Share yours in the comments.
If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.