A request handler hangs on one code path: a .then() callback returns an object from a third-party client that happens to expose a then method, and nothing after it ever runs. What did the promise machinery do with that object, and how would you confirm and contain it?
answer
- duck typing, not instanceof
- any callable then counts
- the chain hands over control
- neither callback means pending forever
- a hang is not a rejection
basics
~20 sAny object with a callable then method is a thenable, so the chain assimilated it: it called then(resolve, reject) and now waits for one of those to be invoked. A thenable that calls neither leaves the chain pending forever.
solid answer
~50 sPromise resolution does not check for native promises — it checks for a **thenable**, any object or function whose `then` property is callable. When a `.then` callback returns one, the machinery schedules a job that calls `thenable.then(resolveFn, rejectFn)` and hands the chain's fate to that foreign code. If the third-party `then` never invokes either function — because it is a different API that happens to be named `then`, or because it swallowed an internal error — the derived promise stays pending forever, and every link after it, including `.catch` and `.finally`, simply never runs. To confirm it, check `typeof value.then === 'function'` on the returned object and call it manually with two logging functions; a promise that is pending with no timer or socket keeping it alive is the signature. To contain it, never chain directly on a foreign thenable: convert deliberately at the boundary and bound the wait, so a misbehaving library cannot leak a stuck request.
code
javascript · 11 linesconst foreign = { then(resolve, reject) { /* never calls either */ } };
console.log(typeof foreign.then === 'function'); // true -> it is a thenable
console.log(foreign instanceof Promise); // false -> not a native promise
Promise.resolve('start')
.then(() => foreign) // assimilated: the chain now waits on foreign.then
.then(() => console.log('unreachable'))
.catch(() => console.log('also unreachable'));
// Nothing is ever logged from the chain: it is permanently pending.go deeper
Know the term: a thenable is any object with a callable then method, and promise chains treat one just like a promise. You are not expected to debug a hang at this level.
Explain assimilation mechanically — resolution reads .then, and if it is callable, calls it with the chain's resolve and reject — and name the outcomes: no call means pending forever, a throwing getter means rejection.
Demonstrate the diagnosis: a request that neither completes nor errors, confirming thenability on the returned value, calling then by hand, and containing it by normalising third-party results at one boundary with a bounded wait.
Own the boundary policy: which external result types are allowed to cross into application code, that every dependency call has an upper bound on pending time, and that hang-shaped failures are alerted on, since they emit no error at all.
## Duck typing at the core of the language Promise resolution is deliberately structural. When a promise is being resolved with a value, the specification asks: is this value an object or function, and is its `then` property callable? If yes, the value is a **thenable** and gets *assimilated* — the engine queues a job (the spec's promise-resolve-thenable job) that calls `then` with two functions the engine created, the chain's own resolve and reject. Whatever that foreign `then` chooses to do decides how your promise settles. This rule predates native promises and exists precisely so that promises from different libraries interoperate. It also means the machinery has no way to tell a real promise from an object that merely has a method spelled `then`. ```js const weird = { then(resolve, reject) { /* forgets to call either */ } }; Promise.resolve('start') .then(() => weird) .then(() => console.log('never runs')) .catch(() => console.log('never runs either')); ``` Nothing prints. The chain is not rejected and not fulfilled — it is permanently pending, which is a state with no handler. ## What can go wrong, concretely **Neither callback is called.** The chain hangs. This is the case in the question. It happens with objects whose `then` means something unrelated (a query builder, a state machine, a records object with a field literally named `then`) and with buggy custom thenables that throw internally into a swallowed context. **The `then` property is a getter with side effects.** Resolution reads `value.then` to test it; a getter runs at that moment. If reading it throws, the promise rejects with that error, which can look like a spurious failure far from its cause. **Both callbacks are called, or one is called repeatedly.** This one is safe: promise settlement is one-shot, so only the first call counts and the rest are ignored. **It resolves with something you did not expect.** A foreign `then` may pass its own object shape, so the next link receives a wrapper rather than the data — and if it passes another thenable, that one is assimilated in turn. **Accidental assimilation.** Any value you try to fulfil with — including one returned from an `async` function — is checked for thenability. An ordinary data object that happens to carry a `then` method is silently unwrapped instead of delivered, which is why "my API returns the object but the caller gets something else" is occasionally a `then`-shaped property, not a serialization bug. ## Confirming it in a real incident The symptom is distinctive: a request that never completes and never errors, with no timeout firing, and a promise that no debugger shows as rejected. Steps that isolate it quickly: 1. Log the value the callback is about to return, plus `typeof value?.then`. If that prints `function` and the value is not a native promise, you have a thenable. 2. Distinguish native from foreign: `value instanceof Promise` is true only for a native promise from the same realm, so a `then` function on something that fails `instanceof` is a strong signal (cross-realm promises from an iframe or Node `vm` also fail `instanceof`, so treat it as a signal, not proof). 3. Call it by hand once: `value.then(v => console.log('resolved', v), e => console.log('rejected', e))`. If neither line ever prints, the foreign implementation is the culprit and you can stop looking at your own chain. 4. In Node, an unhandled-rejection listener stays quiet for this class of bug — silence there plus a stuck request is itself evidence, since a hang is not a rejection. ## Containing it The defensive posture is: do not let foreign objects into the resolution path unexamined. - **Normalise at the boundary.** Adapt third-party results in one adapter function per client, so exactly one place decides what a call returns. If a client's object is not meant to be awaited, return a field of it rather than the object itself. - **Bound the wait.** Any external dependency reached through a chain should have an upper bound on how long the chain may stay pending, so a never-settling thenable degrades into a visible failure instead of a leaked request. - **Keep data objects free of a `then` method.** For anything you construct and pass around as a value, `then` is effectively a reserved name. - **Prefer explicit conversion.** When a library documents a custom promise type, convert it once, deliberately, and let the rest of the code deal only in native promises. ## The interview point The reason this is asked at a senior level is not the trivia that thenables exist — it is the mode of failure. Most promise bugs make noise: a rejection, an error log, an unhandled-rejection warning. Assimilating a broken thenable makes *no* noise at all, and a silent hang under load looks like a leak, a deadlock, or a slow dependency long before anyone suspects a duck-typed `then`.
- How can you tell at runtime whether a value will be assimilated by a chain?Test thenability the way the specification does: `value !== null && (typeof value === 'object' || typeof value === 'function') && typeof value.then === 'function'`. Note that reading `.then` can itself run a getter. `instanceof Promise` answers a narrower question — it is false for foreign thenables and also for native promises from another realm.
- What happens if a thenable calls its resolve function twice, or calls both resolve and reject?Only the first call has any effect. Settlement is one-shot: the resolve and reject functions the engine passes in are single-use, and every later call is ignored. That makes over-calling harmless — the dangerous case is the opposite, calling neither, which leaves the chain pending with no handler that can ever run.
- Why is an ordinary data object with a method named `then` a hazard?Because resolution is structural, that object can never be delivered as a fulfilment value: any attempt to fulfil with it — from a `.then` return, an `async` function's return, or a resolve call — assimilates it instead and calls its method. Treat `then` as a reserved property name on values you pass around.
saying these in an interview costs you the question
- Assuming only real Promise instances are awaited or adopted
- Thinking a stuck chain will eventually time out on its own
- Expecting .catch or .finally to run when a chain merely hangs
- Believing a thenable calling resolve twice corrupts the chain
- Testing with instanceof Promise and concluding it cannot be assimilated