Skip to content

db-postgres: connect() rejects its initializing promise with no argument, producing an unhandledRejection with reason undefined #18656

Open

Description

@iofreddy85

Describe the Bug

@payloadcms/db-postgres rejects its internal initializing promise with no argument when connect() fails. Because the only thing that ever awaits that promise is beginTransaction, a failed connect on a request path that opens no transaction leaves the rejection unhandled — and since the reason is undefined, Node attaches no stack to it.

The result is an unhandledRejection with reason === undefined. In an app with Sentry (or any unhandledRejection reporter) wired up, that arrives as an event with no message, no stack and no in-app frames, pointing only at the reporter's own handler. It is effectively undebuggable from the report: nothing in it names Payload, Postgres, or the connection at all.

The two lines, at v3.90.2:

  • packages/db-postgres/src/index.ts#L80-L82 — the promise is created and no handler is attached:

    const initializing = new Promise<void>((res, rej) => {
      resolveInitializing = res
      rejectInitializing = rej
    })
  • packages/db-postgres/src/connect.ts#L109-L111 — it is rejected with nothing, and then a separate real Error is thrown:

    if (typeof this.rejectInitializing === 'function') {
      this.rejectInitializing()          // ← no argument ⇒ rejects with `undefined`
    }
    throw new Error(`Error: cannot connect to Postgres: ${err.message}`)

Still present on main (packages/db-postgres/src/connect.ts, this.rejectInitializing() immediately before the throw). The type itself guarantees it can never carry a reason — rejectInitializing: () => void, declared with no parameter, in packages/drizzle/src/types.ts and the db-postgres types.

The only consumer is packages/drizzle/src/transactions/beginTransaction.ts (await this.initializing). packages/drizzle/src/destroy.ts replaces the promise rather than awaiting it. So on any read path that does not begin a transaction, nothing is attached when the rejection lands.

Two suggested fixes, either of which is sufficient:

  1. Pass the error: this.rejectInitializing(err), and widen the type to (err?: unknown) => void. The rejection then at least carries a stack for whoever is listening.
  2. Attach a no-op handler where the promise is created, so the orphan can never surface: initializing.catch(() => {}). connect() already throws the real error separately, and beginTransaction's own await still rejects, so nothing is swallowed.

(1) is the more useful of the two; doing both costs nothing.

Link to the code that reproduces this issue

https://github.com/payloadcms/payload/blob/v3.90.2/packages/db-postgres/src/connect.ts#L109-L111

I have not pushed a create-payload-app scaffold for this one, because the defect is visible in the published source above and does not depend on app config: rejectInitializing is typed () => void, it is called with no argument, and the promise it rejects has no handler attached at creation. Happy to put a scaffold together if you'd rather have one to run — say so and I will.

Reproduction Steps

Any postgresAdapter app where connect() fails will do it. Forcing it deterministically:

  1. Build the app and start it (output: standalone in my case, but plain next start behaves the same).

  2. Set connectionTimeoutMillis on the adapter's pool low enough that the pool's timer expires during the first server render, which blocks the event loop while the CMS config is built. With connectionTimeoutMillis: 1 through 10 it fires every time:

    db: postgresAdapter({
      pool: { connectionString: process.env.DATABASE_URL, connectionTimeoutMillis: 1 },
    })
  3. Request any page that reads from the database but opens no transaction (a plain payload.find() in a server component is enough).

  4. Observe two things in the output: Payload's own Error: cannot connect to Postgres: Connection terminated due to connection timeout — correct and useful — and, separately, an unhandledRejection whose reason is undefined.

With a listener installed to inspect it:

process.on('unhandledRejection', (reason) => {
  console.log('typeof reason:', typeof reason, '| isError:', reason instanceof Error)
})
// → typeof reason: undefined | isError: false

Patching the reject capability of new Promise(executor) to log a stack when called with a non-Error names the call site directly:

!! new Promise(…) reject() with a non-Error reason: undefined
   at Object.rejectInitializing (…)
   at Object.i [as connect]  (…/connect.js)
   at async …init            (…)

Ruled out below Payload, by reading the installed source, so that the report lands in the right place:

  • pg-pool@3.13.0 can only reject its connect promise with a truthy value — err ? rej(err) : res(client).
  • pg@8.20.0's Client.end() with no callback returns a promise with no reject path at all.
  • @opentelemetry/instrumentation-pg@0.51.1's handleConnectResult re-rejects the identical error.

Which area(s) are affected?

area: db-postgres

Environment Info

payload:              3.90.2
@payloadcms/db-postgres: 3.90.2
@payloadcms/drizzle:  3.90.2
pg:                   8.20.0
pg-pool:              3.13.0
next:                 15.4.11
node:                 22.16.0 (also reproduced on 22.23.1)

Activity

  1. Metehan-Bicer commented on Oct 10, 2026

    @Metehan-Bicer

    Hi! I'd like to take this. On main the same no-argument rejectInitializing() call is in the connect.ts of db-postgres, db-sqlite, db-vercel-postgres and db-d1-sqlite, and drizzle/src/destroy.ts creates a fresh initializing the same way. I'd apply both of your suggestions to all of them: pass the connect error and widen the type to (err?: unknown) => void, and attach a no-op handler where the promise is created. I'll add a unit test with an adapter whose connect fails.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions