#12861·typeorm

Lazy relation whose load rejects triggers unhandledRejection even when the caller awaits and catches it

Author: koray-datadiveCreated Sep 11, 2026Updated Sep 13, 2026

Issue description

A lazy relation whose load rejects produces an unhandledRejection even when the caller awaits it inside try/catch. On Node ≥ 15 that terminates the process.

Expected Behavior

If application code awaits a lazy relation and handles the rejection, that should be the end of it:

typescript
try {
  const tier = await subscription.subscriptionTier; // lazy relation
} catch (err) {
  // handled — the process should survive
}

Actual Behavior

The caller's catch runs and unhandledRejection fires with the same error, killing the process.

The cause is the bookkeeping .then() in setPromise, inside RelationLoader.enableLazyLoad (src/query-builder/RelationLoader.ts):

typescript
const setPromise = (entity: ObjectLiteral, value: Promise<any>) => {
    delete entity[resolveIndex]
    delete entity[dataIndex]
    entity[promiseIndex] = value
    // eslint-disable-next-line @typescript-eslint/no-floating-promises
    value.then(
        // ensure different value is not assigned yet
        (result) =>
            entity[promiseIndex] === value
                ? setData(entity, result)
                : result,
    )
    return value
}

value.then(onFulfilled) is passed no rejection handler, so it derives a new promise that rejects whenever value rejects. That derived promise is returned to nobody and stored nowhere — entity[promiseIndex] holds value, not the derived chain — so it can never be handled, by the caller or by anyone else.

One failing lazy load therefore rejects twice:

promise reachable by user code? outcome
value (returned by the getter) yes awaited and caught normally
value.then(...) (bookkeeping) no unhandledRejection → process exit

Because the orphan is created inside TypeORM, no amount of care at the call site prevents it, and a lint rule such as @typescript-eslint/no-floating-promises cannot see it.

Steps to reproduce

This drives the real enableLazyLoad, with the underlying load stubbed to reject the way a query timeout or a dropped connection would. No database needed:

javascript
const { RelationLoader } = require('typeorm/query-builder/RelationLoader');

let unhandled = null;
process.on('unhandledRejection', (reason) => { unhandled = reason; });

const loader = Object.create(RelationLoader.prototype);
loader.load = () => Promise.reject(new Error('Query read timeout'));

const entity = {};
loader.enableLazyLoad(
  { propertyName: 'subscriptionTier', isManyToOne: true, isOneToOne: false },
  entity,
  undefined,
);

(async () => {
  try {
    await entity.subscriptionTier;
    console.log('resolved (unexpected)');
  } catch (err) {
    console.log('caller CAUGHT its rejection correctly ->', err.message);
  }

  await new Promise((r) => setTimeout(r, 50)); // let the microtask queue drain

  console.log('unhandledRejection fired?', unhandled ? 'YES -> ' + unhandled.message : 'no');
})();

Output on 0.3.31:

caller CAUGHT its rejection correctly -> Query read timeout
unhandledRejection fired? YES -> Query read timeout

With real entities the same thing happens whenever the relation query fails. We hit it in production with pg's query_timeout: the request returned a correct 500 from the awaited path, and the process died anyway from the orphan. The reported stack has no application frame above getMany, which is what makes it hard to trace back:

QueryFailedError: Query read timeout
    at PostgresQueryRunner.query (.../driver/postgres/PostgresQueryRunner.ts:325:19)
    at SelectQueryBuilder.loadRawResults (.../query-builder/SelectQueryBuilder.ts:3818:25)
    at SelectQueryBuilder.executeEntitiesAndRawResults (.../query-builder/SelectQueryBuilder.ts:3564:26)
    at SelectQueryBuilder.getRawAndEntities (.../query-builder/SelectQueryBuilder.ts:1617:29)
    at SelectQueryBuilder.getMany (.../query-builder/SelectQueryBuilder.ts:1707:25)

My Environment

Dependency Version
Operating System Linux (Amazon ECS Fargate), also reproduced on macOS
Node.js version v24
Typeorm version 0.3.31 — the same code is present in 1.1.1 and on master

Additional Context

Suggested fix: give the bookkeeping chain a rejection handler, so it stops being an orphan. The promise handed to the caller is unchanged and still rejects.

typescript
value.then(
    (result) =>
        entity[promiseIndex] === value ? setData(entity, result) : result,
    () => {
        // the caller owns `value`'s rejection; only clear the cached in-flight promise
        if (entity[promiseIndex] === value) delete entity[promiseIndex]
    },
)

Clearing promiseIndex on failure also means a later access retries the load instead of returning a permanently rejected cached promise.

I verified this against the repro above — the caller still receives the error, and unhandledRejection no longer fires:

caller CAUGHT -> Query read timeout
unhandledRejection fired? no

Happy to open a PR with this plus a regression test if the approach looks right.

Relevant Database Driver(s)

  • postgres

(The defect is driver-independent — it is in RelationLoader — but postgres is where we observed it.)

Are you willing to resolve this issue by submitting a Pull Request?

Yes, I have the time, and I know how to start.