Describe the Bug
On Cloudflare Workers, Payload's postgres adapter shares one pg.Pool across all requests on an isolate: getPayload() caches the Payload instance — including the adapter and its pool — in global._payload, and Workers reuse isolates across unrelated requests.
Workers require that a promise is resolved in the same request context that created it. When a query is slow (for us: a serverless Postgres compute waking from autosuspend), the Workers runtime eventually force-cancels the originating request — but the database response still arrives later, inside a different request's context. The result:
Warning: A promise was resolved or rejected from a different request context than the one it was created in, with a stack in the pg driver (Query.handleReadyForQuery via CloudflareSocket).
Error: The Workers runtime canceled this request because it detected that your Worker's code had hung and would never generate a response.
- Cloudflare edge
Error 1101 — Worker threw exception.
Worse than the individual failure: the shared pool's internal state is left corrupted, and unrelated subsequent requests on the same isolate fail too — cascading failures on admin routes that had nothing to do with the original slow query.
maxUses: 1 (per Cloudflare's Payload-on-Workers blog post) does not prevent this — we run it verbatim. It forces a fresh connection per query but says nothing about pool lifecycle: the pool object itself, its promise machinery, and its error state still outlive every request. This isn't Payload-specific either; Prisma has the identical open issue under the same conditions: prisma/prisma#28732.
To Reproduce
- Deploy Payload with
@payloadcms/db-postgres on Cloudflare Workers (via @opennextjs/cloudflare), pool configured with maxUses: 1, against a Postgres that cold-starts (we use Neon with default ~5 min autosuspend, behind Hyperdrive).
- Let the database compute suspend (5+ min without traffic).
- Fire several rapid admin page loads within the first ~30–60 s of the compute waking, while watching
wrangler tail.
- A request hangs and is force-cancelled; the cross-request-context warning appears; subsequent unrelated admin routes on the same isolate start failing.
Reproduced reliably in 2 out of 2 attempts. Any deployment is affected whenever traffic is low enough for the database compute to suspend — steady traffic only masks the problem.
Adding connectionTimeoutMillis / statement_timeout / query_timeout to the pool config mitigates (stuck queries fail fast instead of hanging until force-cancel) and is worth documenting, but it narrows the window rather than removing the mechanism.
The fix we verified: request-scoped pools
A pool and its promises must not outlive the request that created them. We implemented this without patching Payload, and it eliminated the issue under the same reproduction recipe (no context warnings, no cascading failures, isolate recovers instantly):
- A custom Worker entry opens an
AsyncLocalStorage scope per request and closes the request's pool after the response body finishes streaming (ctx.waitUntil).
- The adapter's
drizzle property is replaced with an accessor: inside a request scope it lazily creates a per-request pg.Pool + drizzle handle (cheap — the expensive schema build stays cached); outside a scope (init, migrations, local dev) it falls back to the shared handle.
Sketch of the accessor (full implementation available, happy to share/PR):
Object.defineProperty(adapter, 'drizzle', {
get() {
const store = requestStorage.getStore()
if (!store || store.ended || !baseDrizzle) return baseDrizzle
store.drizzle ??= drizzle({
client: (store.pool = new adapter.pg.Pool(adapter.poolOptions)),
logger: adapter.logger ?? false,
schema: adapter.schema,
})
return store.drizzle
},
set(value) {
baseDrizzle = value
},
})
This adds no connection overhead in the recommended setup: with maxUses: 1 every query already opens a fresh connection, and pools are lazy.
This works because in @payloadcms/db-postgres / @payloadcms/drizzle 3.85.0 all query-time database access resolves adapter.drizzle (or adapter.sessions[id].db) via property reads at call time — nothing captures the handle at init. That's an internal detail we'd rather not depend on across upgrades, hence this issue.
Proposal
A first-class, supported way to scope connections to a request on serverless runtimes — e.g. an adapter option like connectionLifecycle: 'request' on postgresAdapter, with the ALS scope opened by the adapter (or a documented hook for the host to open it). We're happy to contribute a PR based on the implementation above if maintainers agree on the shape.
Also worth a docs note either way: the Cloudflare guidance of maxUses: 1 alone is not sufficient to run db-postgres safely on Workers, and pool timeouts should be set.
Environment
- Payload: 3.85.0 (
@payloadcms/db-postgres, @payloadcms/drizzle 3.85.0)
- Next.js 16.2.6 via
@opennextjs/cloudflare 1.19.11, wrangler 4.94
pg 8.21 (with pg-cloudflare), Cloudflare Hyperdrive → Neon Postgres 17 (autosuspend enabled)
- Node compat:
nodejs_compat, compatibility_date 2025-09-15
Describe the Bug
On Cloudflare Workers, Payload's postgres adapter shares one
pg.Poolacross all requests on an isolate:getPayload()caches the Payload instance — including the adapter and its pool — inglobal._payload, and Workers reuse isolates across unrelated requests.Workers require that a promise is resolved in the same request context that created it. When a query is slow (for us: a serverless Postgres compute waking from autosuspend), the Workers runtime eventually force-cancels the originating request — but the database response still arrives later, inside a different request's context. The result:
Warning: A promise was resolved or rejected from a different request context than the one it was created in, with a stack in thepgdriver (Query.handleReadyForQueryviaCloudflareSocket).Error: The Workers runtime canceled this request because it detected that your Worker's code had hung and would never generate a response.Error 1101 — Worker threw exception.Worse than the individual failure: the shared pool's internal state is left corrupted, and unrelated subsequent requests on the same isolate fail too — cascading failures on admin routes that had nothing to do with the original slow query.
maxUses: 1(per Cloudflare's Payload-on-Workers blog post) does not prevent this — we run it verbatim. It forces a fresh connection per query but says nothing about pool lifecycle: the pool object itself, its promise machinery, and its error state still outlive every request. This isn't Payload-specific either; Prisma has the identical open issue under the same conditions: prisma/prisma#28732.To Reproduce
@payloadcms/db-postgreson Cloudflare Workers (via@opennextjs/cloudflare), pool configured withmaxUses: 1, against a Postgres that cold-starts (we use Neon with default ~5 min autosuspend, behind Hyperdrive).wrangler tail.Reproduced reliably in 2 out of 2 attempts. Any deployment is affected whenever traffic is low enough for the database compute to suspend — steady traffic only masks the problem.
Adding
connectionTimeoutMillis/statement_timeout/query_timeoutto the pool config mitigates (stuck queries fail fast instead of hanging until force-cancel) and is worth documenting, but it narrows the window rather than removing the mechanism.The fix we verified: request-scoped pools
A pool and its promises must not outlive the request that created them. We implemented this without patching Payload, and it eliminated the issue under the same reproduction recipe (no context warnings, no cascading failures, isolate recovers instantly):
AsyncLocalStoragescope per request and closes the request's pool after the response body finishes streaming (ctx.waitUntil).drizzleproperty is replaced with an accessor: inside a request scope it lazily creates a per-requestpg.Pool+ drizzle handle (cheap — the expensive schema build stays cached); outside a scope (init, migrations, local dev) it falls back to the shared handle.Sketch of the accessor (full implementation available, happy to share/PR):
This adds no connection overhead in the recommended setup: with
maxUses: 1every query already opens a fresh connection, and pools are lazy.This works because in
@payloadcms/db-postgres/@payloadcms/drizzle3.85.0 all query-time database access resolvesadapter.drizzle(oradapter.sessions[id].db) via property reads at call time — nothing captures the handle at init. That's an internal detail we'd rather not depend on across upgrades, hence this issue.Proposal
A first-class, supported way to scope connections to a request on serverless runtimes — e.g. an adapter option like
connectionLifecycle: 'request'onpostgresAdapter, with the ALS scope opened by the adapter (or a documented hook for the host to open it). We're happy to contribute a PR based on the implementation above if maintainers agree on the shape.Also worth a docs note either way: the Cloudflare guidance of
maxUses: 1alone is not sufficient to run db-postgres safely on Workers, and pool timeouts should be set.Environment
@payloadcms/db-postgres,@payloadcms/drizzle3.85.0)@opennextjs/cloudflare1.19.11, wrangler 4.94pg8.21 (withpg-cloudflare), Cloudflare Hyperdrive → Neon Postgres 17 (autosuspend enabled)nodejs_compat, compatibility_date 2025-09-15