When Promises Float: Stress-Testing Biome’s Lint Rule

Biome’s noFloatingPromises lint rule targets unhandled Promises—those created in a way that their rejections can never be caught or observed. A Promise is safe when it’s awaited, assigned, returned from an async function, passed to the void operator, or chained with .then(...).catch(...). Without those, a floating Promise can silently break control flow and crash production in ways that are notoriously hard to debug.

To harden the rule against subtle edge cases, the Biome team worked with Vercel engineers, who turned the effort into an internal competition: who could craft the trickiest examples that still slipped past the linter? The submissions, collected below, range from pragmatic type-system quirks to deliberately absurd runtime tricks.

Arrays of Promises

An array holding multiple Promises can look harmless if the array itself is never stored or awaited. Every inner Promise in the expression below is left floating:

[1, 2, 3].map(async (x) => x + 1)

Promise-Like Objects and Structural Typing

TypeScript distinguishes between a true Promise and a structurally similar PromiseLike object. Returning the former is easy to catch:

function normalPromise(): Promise<number> {

return new Promise((_, reject) => reject(2))

}

normalPromise() // linter warns: floating Promise

But PromiseLike only matches structurally—not by name—so a call like promiseLike() that ignores its rejection leaves an unhandled async result floating. typescript-eslint handles this class with its checkThenables option, and Biome’s updated rule needed similar coverage.

function promiseLike(): PromiseLike<number> {

return new Promise((_, reject) => reject(2))

}

promiseLike() // floating Promise

The same structural-typing loophole applies when you copy TypeScript’s built-in Promise shape under a different name. This Duck type behaves exactly like a Promise, yet name-based lint checks won’t recognize it:

/** a direct copy of the TypeScript `Promise` type, but with a different name */

interface Duck<T> {

then<TResult1 = T, TResult2 = never>(

onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null,

onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null

): Promise<TResult1 | TResult2>

catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>

}

function promise(): Duck<string> {

return new Promise((_, reject) => reject(2))

}

promise() // floating Promise (thenable)

Conditional Type Aliases

Returning a Promise directly is straightforward for a type-aware linter to flag:

async function promiseLike() {

return new Promise((_, reject) => reject(2))

}

promiseLike() // linter warns: floating Promise

Wrapping it in a generic conditional alias obscures the result at the call site. Even when Cheating<T> always resolves to Promise<string>, the indirection can defeat a rule that only checks for the literal Promise type:

type Cheating<T extends 1> = T extends 1 ? Promise<string> : Promise<string>

async function promiseLike(): Cheating<1> {

return new Promise((_, reject) => reject(2))

}

promiseLike() // floating Promise

A rejecting Promise returned from promiseLike(): Cheating<1> still floats if ignored.

Proxy-Based Promises

JavaScript’s Proxy can intercept property access to hide asynchronous behavior. Calling methods on a normal Promise is transparent to the linter:

new Promise((_, reject) => reject(2)).then(() => {})

But with a Proxy, accessing any non-Promise property can secretly trigger an async side effect. Even the result of lazy.then(...) in this contrived example is a Promise that goes unhandled:

function createLazyPromise<

T extends string,

U extends (prop: PropertyKey) => Promise<T>,

>(getValue: U) {

let resolve: (value: T) => void

const promise = new Promise<T>((r) => {

resolve = r

return r

})

const proxy = new Proxy(promise as Promise<T>, {

get(target, prop, receiver) {

if (prop in target) {

return Reflect.get(target, prop, receiver)

}

// Access to any other property triggers resolution

getValue(prop).then(resolve) // floating promise

return undefined // Could also return another proxy here

},

})

return proxy as Promise<T>

}

const lazy = createLazyPromise((prop) =>

Promise.resolve(`You accessed: ${String(prop)}`),

)

lazy.then((result) => {

console.log(result) // floating Promise

})

(lazy as any).foo // floating Promise

Frozen and Readonly Promises

Freezing a Promise changes its type in a way that complicates detection. Object.freeze() wraps the argument in the Readonly utility type, which masks the underlying Promise type:

Object.freeze(new Promise((_, reject) => reject(2)))

Object Members, Getters, and Mapped Types

Wrapping a Promise inside an object method or getter hides it behind an extra layer of indirection. A simple member function that returns a rejecting Promise is an easy miss:

const sneakyObject = {

rejectSomething() {

return new Promise((_, reject) => reject(2))

}

}

sneakyObject.rejectSomething() // floating Promise

JavaScript’s get syntax achieves the same effect; at a glance, the property access looks safe, but it’s returning an unhandled Promise:

const sneakyObject2 = {

get something() {

return new Promise((_, reject) => reject(2))

},

}

sneakyObject2.something // floating Promise

Mapped types take the trick further by manufacturing get* methods that return Promises. Calling lazyThings.getThing() without await looks like a field read but leaves a floating async operation:

interface Things {

Thing: string

}

type CalculateGetter<T> = {

[K in keyof T as K extends string ? `get${K}` : never]: () => Promise<T[K]>

}

declare const lazyThings: CalculateGetter<Things>

lazyThings.getThing() // floating Promise

Operators and Expression Positions

Logical operators return the value of one of their operands—they don’t await or handle side effects. A true && <Promise> expression yields the Promise in pure expression position, and nothing catches its rejection:

true && new Promise((_, reject) => reject(2)) // floating Promise

Randomness in control flow doesn’t excuse the rule. If an expression’s type is Promise<unknown> | null, the Promise branch still counts as floating when it occurs:

Math.random() > 0.5

? new Promise((_, reject) => reject(2)) // floating Promise

: null

Optional chaining with a fallback can trigger the same problem. When the optional call returns undefined, the || eagerly constructs a rejecting Promise that lives only in expression position:

const optionalObject: Record<string, (() => unknown) | undefined> = {}

optionalObject?.nonExistentMethod?.() || new Promise((_, reject) => reject(2))

Immediately invoked function expressions (IIFEs) don’t change the underlying issue—wrapping a Promise in a function call doesn’t handle its rejection:

(() => new Promise((_, reject) => reject(2)))()

The comma operator, common in minified code, discards its left operand and returns the right one. In this example, the result is a Promise that’s ignored and floats:

let _x = 5

_x++, new Promise((_, reject) => reject(2))

The Verdict

From an array of submissions, the Proxy-based Promise took the prize for being the least practical, most convoluted case—which was precisely the point. The exercise wasn’t about finding real-world bugs but about pushing the linter’s implementation to its limits. Many of the issues uncovered by these snippets have since been fixed in Biome.

Linters, like any software, must prioritize the cases most likely to hit users. Still, creative stress-testing remains valuable for closing the gaps between type-theoretical edge cases and everyday code.