JavaScript is a fantastic server-side language because it's async. That also makes it tricky. 💩
Async means you can write code that doesn't hit the usual thread-safety problems and lets you run multiple tasks in "parallel". Like waiting for a file read without blocking computation.
But subtle bugs in your async code can lead to unresolved promises. Code that never finishes.
We ran into this when our knex database connection pool kept running out of available connections and crashing the service. Normally a connection runs its query and returns to the pool for another query to use.
Something was hogging those connections.
With dozens of programmers banging away at a few million lines of code across several years, maybe unresolved promises are the problem? I decided to investigate.
When you're trying to fix a bug and Google starts finding academic papers about your issue pic.twitter.com/qZ5AyKZR4t
— Swizec Teller (@Swizec) October 20, 2021
The halting problem rears its head
Finding unresolved promises is an instance of the halting problem – a provably unsolvable problem.
There is no algorithm you can write that can look at any piece of code and answer "Will this always finish for every input?". You can't do it by hand either.
Google finds a few cases where people talk about unresolved promises. The answer is always "Don't write fundamentally broken code"
The typical halting problem solution is to add a time constraint. If a function doesn't resolve within N seconds, we assume it's stuck. It may not be, but we assume it is and kill the program.
This is why connection timeouts exist. You don't want to wait forever, if a server gets stuck. Important when building distributed systems.
But killing your server process for every hiccup is not ideal. And besides, adding timeouts to every single promise in your entire codebase is hard at best.
HOWEVER! You can solve the halting problem for a subset of common patterns 🥳
Patterns for broken promises
In their paper Finding Broken Promises in Asynchronous JavaScript Programs, Alimadadi et al highlight common patterns that lead to unresolved promises and share PromiseKeeper, a software that finds potentially unresolved promises using promise graphs.
Ok so I read the paper that Google found and omg I can't remember the last time I learned so much cool stuff in 1 hour.
— Swizec Teller (@Swizec) October 20, 2021
- 6 patterns that lead to unresolved promises
- details of how promises work I never noticed
- proof that all code is bad
- a tool that finds bad promises 🤞
Review of JavaScript promises
The paper starts with a review of JavaScript promises.
Promises represent an asynchronous computation and can be in 3 states: pending
, fulfilled
, and rejected
. They start as pending.
You can register reactions to promises with .then()
.
// immediately resolves with value 17
const promise = Promise.resolve(17)
promise.then(
function fulfilledReaction(value) {
console.log({ value })
},
function rejectedReaction(error) {
console.log({ error })
throw error
}
)
Code in practice omits the 2nd parameter to focus on fulfilled reactions. Common when building promise chains:
// immediately resolves with value 17
const promise = Promise.resolve(17)
promise.then(value => value + 1)
.then(value => value + 1)
.then(function (value) => { console.log(value) })
Each call to .then
creates a new promise, which resolves with the return value of the reaction. Notice that the last call implicitly resolves with undefined
. Because in JavaScript a function without a return implicitly returns undefined
.
Important detail 💡
We can add error handling to a promise chain using .catch()
:
// immediately resolves with value 17
const promise = Promise.resolve(17)
promise.then( ... )
.then( ... )
.then( ... )
.catch(err => ...)
Every promise created by .then
implicitly defines a default rejection reaction equivalent to err => throw err
. This means a .catch()
at the end of a chain can react to errors in any of the above promises.
Relying on default fulfill reactions is less common, but this is valid code:
// immediately resolves with value 17
const promise = Promise.resolve(17)
promise
.then(undefined) // uses default value => value reaction
.then((value) => console.log(value))
I think this happens by accident more than on purpose 🤷♀️
You can link promises by using a promise to resolve another promise:
const p0 = Promise.resolve(17) // immediately resolves
const p1 = Promise.reject("foo") // immediately rejects
p0.then(function (v) {
return p1
})
The state of p0
is now linked to p1
. Meaning the unnamed promise created on line 3 gets rejected with "foo"
.
You see this a lot in code. Often less obvious.
Pattern 1: Unhandled promise rejections
A common source of trouble are unhandled promise rejections.
This happens when you implicitly reject a promise by throwing an error in your fulfilled reaction.
promise.then(function (val) {
if (val > 5) {
console.log(val)
} else {
throw new Error("Small val")
}
})
Because the fulfill reaction runs in a separate async context, JavaScript doesn't propagate this error to the main thread. The error gets swallowed and you'll never know it happened.
You can fix this with a .catch()
reaction:
promise
.then(function (val) {
if (val > 5) {
console.log(val)
} else {
throw new Error("Small val")
}
})
.catch((err) => console.log(err))
You now have a chance to handle the error.
But you didn't re-throw! If other linked or chained promises rely on this code, the error remains swallowed. Your code keeps running.
Try this in a browser console:
const p = Promise.resolve(17)
p.then(function (val) {
throw new Error("Oops")
return val + 1
})
.catch(function (err) {
console.log(err)
})
.then(function (val) {
console.log(val + 1) // prints NaN
})
You're expecting 17 + 1 = 18
but you get NaN
thanks to an unexpected error. The implicit promise that .catch()
creates is implicitly resolved (not rejected) with undefined
.
Silly example, yes, but imagine how common this pattern becomes in a sprawling codebase where any function may throw for any reason.
Pattern 2: Unsettled promises
Every new promise is in the pending state until resolved or rejected. However, not settling a promise results in a dead promise, forever pending, preventing the execution of reactions that depend on the promise being settled.
These are the unresolved promises that are hardest to find. You cannot know from outside whether a promise is slow or dead.
The authors of Finding Broken Promises in Asynchronous JavaScript Programs share an example issue from node-promise-mysql where connection.release()
returns a promise that never resolves.
That example is hard to condense so here is something simpler:
const p0 = new Promise((resolve, reject) => null)
const p1 = Promise.resolve(17)
p0.then((result) => p1)
.then((value) => value + 1)
.then((value) => console.log(value)) // expecting 18
The last promise chains onto p0
, which neither resolves nor rejects. You can keep this code running forever and it's never going to print a value.
Again, this is a silly example but imagine a sprawling codebase with dozens of programmers. It may not be obvious that a function won't resolve its promise in some cases.
Pattern 3: Implicit returns and reactions
promise chains break silently when the developer forgets to explicitly include a return statement
Similar to the error swallowing example I added above. Here's a piece of code Alimadadi et al share from Google Assistant:
handleRequest (handler) {
if (typeof handler === 'function') {
const promise = handler(this)
if (promise instanceof Promise) {
promise.then(result => {
debug(result)
return result
}).catch(reason => {
this.handleError('function failed')
this.tell(!reason.message ? ERROR_MESSAGE : reason.message)
return reason
})
}
}
}
The handleRequest
method uses a Map of developer-provided handlers to asynchronously address Assistant requests. A handler
can be either a callback or a promise.
If the promise resolves and calls your anonymous handler, this code returns the result. If it rejects, it returns a reason.
However, those returns are inside a promise reaction. But the promise isn't returned! The result of reacting to the handler
promise is lost.
You, as the user of this library, cannot handle the fulfill/reject reactions of promises returned by your own handlers. 💩
Finding anti-patterns with promise graphs
Alimadadi et al created PromiseKeeper, a piece of software that dynamically analyzes a JavaScript codebase and draws a promise graph.
I wasn't able to get the code running, but here's what they had to say.
Promise graphs
You can think of your asynchronous code as a graph where nodes (promises, functions, values, synchronizations) are connected by edges (resolve/fulfill, register, link, return/throw).
-
promise nodes (p) represent each execution of a promise
-
value nodes (v) represent values that resolve or reject a promise. These may be functions.
-
function nodes (f) represent every function that's registered as a reaction to a promise
-
synchronization nodes (s) represent every instance of
Promise.all
orPromise.race
-
resolve/reject edges (v)->(p) show connections from a value node to a promise node. Labeled as
resolve
orreject
-
registration edges (p)->(f) show connections from a promise to a function. Labeled as
onResolve
oronReject
-
link edges (p1)->(p2) show connections between linked promises
-
return/throw edges (f)->(v) show connections from a function to a value. Labeled as
return
orthrow
-
synchronization edges show connections from multiple promises to a single synchronization. Labeled as
resolved
,rejected
, orpending
based on how the new promise behaves
Here's an annotated version of the graph above.
PromiseKeeper for finding anti-patterns
PromiseKeeper aims to construct and visualize promise graphs of your code dynamically while running tests. The dynamic execution context allows it to find anti-patterns that aren't visible by analyzing the code itself.
The anti-patterns it highlights are:
- missing reject reactions that lead to swallowed errors
- attempting to settle a promise multiple times which happens when you try to resolve or reject a previously settled promise
- unsettled promises where a promise neither resolves nor rejects while PromiseKeeper constructs a graph
- unreachable reactions where a registered reaction did not execute during PromiseKeeper's dynamic analysis
- implicit returns and reactions where it may lead to unexpected behavior in promises further down the chain
- unnecessary promises where you explicitly construct a new promise in a function that's already wrapped in a promise
Because PromiseKeeper relies on dynamic code execution, this analysis is only as good as your test coverage. It cannot investigate un-executed code which may lead to false positives.
PromiseKeeper's implementation is based on the Jalangi instrumentation framework. It defines callbacks that hook into the promise lifecycle.
I couldn't get PromiseKeeper to work on my machine, but Alimadadi et al report that subtly broken promises lurk in almost every JavaScript codebase.
Interestingly, the 1012 instances of unsettled promises for Node Fetch happen in just 17 unique locations in the code.
The authors report that 43% of all promises in their experiment were unresolved at the end of execution. It's more likely this indicates incomplete test suites than popular software being completely broken.
What you can do in practice
Keep common anti-patterns in mind and avoid writing fundamentally broken code. Using async/await makes many of these patterns less likely.
We've had great success by logging unhandled rejections with a full stack trace using this bit of code.
// logs a helpful error message when there's
// an unhandled promise rejection
process.on("unhandledRejection", (err, promise) => {
const stack = err instanceof Error ? err.stack : ""
const message = err instanceof Error ? err.message : err
Logger.error("Unhandled promise rejection", {
message,
stack,
promise,
})
})
You can also try Node's async_hooks module to hook into the promise/async lifecycle and attempt to detect long running promises. You could compare start/end times to a max timeout, for example, and log a warning.
My attempt at using async_hooks to detect long running promises in JavaScript was fun, but not super useful. You can't get a reference to execution context (only a C pointer). Means you can see that something is slow, but not what. 💩
I'm tempted to try turning PromiseKeeper into a Jest plugin of sorts. Imagine getting one of those promise graphs any time you run tests 😍
Cheers,
~Swizec
Continue reading about Finding unresolved promises in JavaScript
Semantically similar articles hand-picked by GPT-4
- Waiting for Godot with callbacks, promises, and async
- Async, await, catch – error handling that won't drive you crazy
- A promises gotcha that will catch you out
- 90% of performance is data access patterns
- Promise.allSettled, a wonderful tool for resilient code
Want to become a JavaScript expert?
Learning from tutorials is great! You follow some steps, learn a smol lesson, and feel like you got this. Then you go into an interview, get a question from the boss, or encounter a new situation and o-oh.
Shit, how does this work again? 😅
That's the problem with tutorials. They're not how the world works. Real software is a mess. A best-effort pile of duct tape and chewing gum. You need deep understanding, not recipes.
Leave your email and get the JavaScript Essays series - a series of curated essays and experiments on modern JavaScript. Lessons learned from practice building production software.
Curated JavaScript Essays
Get a series of curated essays on JavaScript. Lessons and insights from building software for production. No bullshit.
Have a burning question that you think I can answer? Hit me up on twitter and I'll do my best.
Who am I and who do I help? I'm Swizec Teller and I turn coders into engineers with "Raw and honest from the heart!" writing. No bullshit. Real insights into the career and skills of a modern software engineer.
Want to become a true senior engineer? Take ownership, have autonomy, and be a force multiplier on your team. The Senior Engineer Mindset ebook can help 👉 swizec.com/senior-mindset. These are the shifts in mindset that unlocked my career.
Curious about Serverless and the modern backend? Check out Serverless Handbook, for frontend engineers 👉 ServerlessHandbook.dev
Want to Stop copy pasting D3 examples and create data visualizations of your own? Learn how to build scalable dataviz React components your whole team can understand with React for Data Visualization
Want to get my best emails on JavaScript, React, Serverless, Fullstack Web, or Indie Hacking? Check out swizec.com/collections
Did someone amazing share this letter with you? Wonderful! You can sign up for my weekly letters for software engineers on their path to greatness, here: swizec.com/blog
Want to brush up on your modern JavaScript syntax? Check out my interactive cheatsheet: es6cheatsheet.com
By the way, just in case no one has told you it yet today: I love and appreciate you for who you are ❤️