Recently we partnered with the Biome team to strengthen their noFloatingPromiseslint rule to catch more subtle edge cases. This rule prevents unhandled Promises, which can cause silent errors and unpredictable behavior. Once Biome had an early version ready, they asked if we could help stress test it with some test cases.
At Vercel, we know good tests require creativity just as much as attention to detail. To ensure strong coverage, we wanted to stretch the rule to its limits and so we thought it would be fun to turn this into a friendly internal competition. Who could come up with the trickiest examples that would still break the updated lint rule?
Part of the fun was learning together, but before we dive into the snippets, let’s revisit what makes a Promise “float”.
A Promise is “floating” when it’s created in a way that its errors can never be handled or observed. A Promise is not considered floating if it’s awaited, assigned to a variable, returned from an async function, called with the void operator, or has .then(...).catch(...) handlers.
This lint rule is important because broken control flow and unhandled errors resulting from floating Promises are notoriously hard to catch. Many engineers have campfire stories of floating Promises taking down production.
With that in mind, let’s look at some of the edge cases our engineers came up with.
Let’s kick it off with a great example of “smarter not harder”. This snippet was taken directly from typescript-eslint’s docs for their floating Promises rule.
[1,2,3].map(async(x)=> x +1)
It might look harmless, but it's tricky because the array of Promises is never stored or awaited, leaving all inner Promises floating.
You’ve made a new type called Duck that looks exactly like a Promise, just with a different name. Because TypeScript uses “structural” typing, Duck behaves like a Promise but slips past lint rules that only check names. The name itself is a nod to “duck typing”, referencing the old idiom “if it walks like a duck, and talks like a duck, then it's a duck”.
Although Cheating<T> always resolves to a Promise<string>, the conditional type and type parameter make that less obvious at the call site. If the lint rule only checks keys on the literal Promise name (instead of “thenables”), promiseLike(): Cheating<1> can slip past. The call to promiseLike() still returns a rejecting Promise, which is ignored and still floats.
The JavaScript Proxy object is useful to those that absolutely need it and incomprehensibly complex to everyone else. So it’s no wonder that we got one submission that ran with the idea “I bet I can use Proxy objects to break this”. This is perhaps the submission least likely to occur in production, but hey, a type-level hole is a type-level hole.
Normally, if you call methods on a Promise, the linter knows what’s going on:
newPromise((_, reject)=>reject(2)).then(()=>{})
But with JavaScript’s Proxy, you can intercept property access and sneak in hidden async behavior:
function createLazyPromise<
Textendsstring,
Uextends(prop: PropertyKey)=>Promise<T>,
>(getValue:U){
letresolve:(value: T)=>void
const promise =newPromise<T>((r)=>{
resolve = r
return r
})
const proxy =newProxy(promise asPromise<T>,{
get(target, prop, receiver){
if(prop in target){
returnReflect.get(target, prop, receiver)
}
// Access to any other property triggers resolution
getValue(prop).then(resolve)// floating promise
returnundefined// Could also return another proxy here
},
})
return proxy asPromise<T>
}
const lazy =createLazyPromise((prop)=>
Promise.resolve(`You accessed: ${String(prop)}`),
)
lazy.then((result)=>{
console.log(result)// floating Promise
})
(lazy asany).foo // floating Promise
Here, accessing any non-Promise property on lazy secretly triggers an async side effect that floats unhandled. Even the lazy.then(...) call returns a Promise that goes unhandled, compounding the drift. It’s a contrived example, but it shows how flexible (and fragile) type inference can be.
Freezing a Promise changes its type in ways that complicate detection.
Object.freeze(newPromise((_, reject)=>reject(2)))
The type of this expression is Readonly<Promise<unknown>>. That’s because Object.freeze() prevents writing on any object you pass it. It’s tricky because you’re wrapping the type with the Readonly utility type which makes it harder for a linter to catch.
JavaScript getters can also hide Promises behind property access.
const sneakyObject2 ={
getsomething(){
returnnewPromise((_, reject)=>reject(2))
},
}
sneakyObject2.something // floating Promise
Similar to the last example, you can use JavaScript’s get syntax to hide the floating Promise. At a glance it looks like a simple property, but it’s actually returning an unhandled Promise.
Normally, accessing an object property feels safe. But with a mapped type, even a getter can hide a Promise. This snippet disguises an async call as a harmless property getter.
The mapped type manufactures get* methods that return Promises, but calling lazyThings.getThing() without await (or .then) leaves that Promise floating. It looks like a simple field read, yet the generic sleight of hand turns it into an unhandled async operation. Good luck ever catching this in code reviews!
Classic short-circuit shenanigans: true && <Promise> evaluates to the right-hand side, yielding a Promise value in pure expression position. Since nothing awaits or .catches it, the rejection goes unhandled. It’s a reminder that && doesn’t run effects and wait. It actually returns a value, which here happens to be a volatile Promise.
Maybe there’s some value that only exists at runtime, perhaps from user input or some API. In that case, it still should be a lint error. The type of this expression is Promise<unknown> | null - and that’s good enough to fit the bill!
Optional chaining with fallbacks can produce Promises that go unhandled. Similar to the last two, you can trigger the “short circuiting” however you want, and this example uses optional chaining.
The optional call returns undefined, so || eagerly evaluates the right-hand side which constructs a rejecting Promise. Because that Promise lives only in expression position (never assigned, awaited, or .catched), the rejection floats silently behind an innocent-looking fallback.
Last up, we have the obscure comma operator. If you’ve ever looked at minified JavaScript you’ve likely seen this in action.
let _x =5
_x++,newPromise((_, reject)=>reject(2))
The result of the expression is the Promise, which is ignored and left floating. It’s a syntactically tight way to discard the left expression and return whatever’s on the right. It once had niche uses, but today it’s mostly syntactic trivia. Still, it’s one more case a linter has to catch.
After consideration from an esteemed panel of judges, it was decided that the Proxy Promise submission was the winner. It was the least practical, most convoluted, and most obscure example. But that's exactly what we were looking for. The goal was never to find real-world bugs, but to stress test the implementation with any trick we could imagine.
These snippets helped the Biome maintainers quickly fix issues, many of which are now resolved today. Like any software, linters need to prioritize the cases most likely to affect users. If you have an edge case of your own that isn't fixed, consider sending Biome a PR!
This competition was just as much about culture as it was about improving the lint rule. It gave us the chance to collaborate, get creative, support open source, and share in some friendly mischief. That reflects the culture we're building at Vercel: solving hard problems together while having fun along the way.
Our engineers care deeply about improving the developer experience, building fast and reliable systems, and giving back to the open source community. We thrive on curiosity, creativity, and collaboration, whether that’s designing scalable infrastructure, pushing the boundaries of web performance, or inventing new ways to make developers more productive.