Skip to content

Hyperdrive request-scoped pool teardown remains pending and triggers waitUntil() cancellation #2133

Description

@diogoascarneiro

Description

On Cloudflare Workers with Postgres through Hyperdrive, some otherwise successful requests leave the request-scoped pg.Pool teardown pending until the Workers runtime cancels waitUntil() after 30 seconds.

The HTTP response itself succeeds. A representative request returned a bodyless 301 with outcome: "ok", cpuTimeMs: 6, and wallTimeMs: 29997. The same invocation then emitted:

waitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled.

Temporary instrumentation around db.destroy() consistently shows this pattern across many sampled requests:

initial.idle = 0
initial.nonIdle = initial.total
current = initial

initial is captured immediately before db.destroy(). current is captured five seconds later if the destroy promise is still pending. In every sampled slow teardown, every pool connection was non-idle when shutdown started, and no pool counter changed during the first five seconds.

Observed request classes include:

  • A bodyless 301 from a public HTML navigation without its canonical trailing slash.
  • Public HTML and malformed-path requests that already include a trailing slash.
  • A HEAD request for a missing static asset.
  • A normal localized content request.

The issue is not specific to redirects or missing trailing slashes. An immediate bodyless redirect is only a high-signal reproduction path because it ends the response soon after background layout prefetch starts. The underlying condition is that the response ends while request-scoped background work still owns or is connecting pool clients.

The clearest sample is an HTML navigation to /tag/cli without a trailing slash:

  • The request's leading Accept media type was text/html, so anonymous layout prefetch was enabled.
  • Application middleware returned an immediate bodyless 301 to /tag/cli/.
  • Pool teardown started with two connections and both were non-idle.
  • Five seconds later the pool state was unchanged.
  • No pool-close completion or failure event followed.
  • At approximately 30 seconds, the Workers runtime canceled the pending waitUntil() work.

The installed code appears to permit a lifecycle race:

  • emdash/src/astro/middleware.ts starts prefetchLayoutData() through after(), using the same request-scoped database from AsyncLocalStorage.
  • The prefetch is intentionally allowed to continue beyond the response.
  • emdash/src/astro/middleware/scoped-db.ts closes bodyless responses immediately and streamed responses when the body finishes.
  • @emdash-cms/cloudflare/src/db/hyperdrive.ts calls waitUntil(db.destroy()) from that close hook.

This can begin pool shutdown while background work still owns or is connecting pool clients. pg.Pool.end() then remains pending while waiting for those clients, and the clients do not return before the Workers runtime's 30-second post-response deadline.

A live pg_stat_activity snapshot on PlanetScale showed only ordinary idle / ClientRead Hyperdrive origin sessions, with no active long-running queries, lock waits, or idle in transaction sessions. That does not indicate a PlanetScale-side query or lock stall. Hyperdrive's origin pool is separate from the per-request pg.Pool inside the Worker.

Expected behavior:

  • EmDash should not begin request-scoped pool teardown while background work still uses that pool.
  • Every request-scoped pool teardown should settle without reaching the Workers waitUntil() cancellation deadline.
  • Background layout prefetch should either finish before teardown, use a separately-owned connection scope, or be skipped/canceled safely for responses that end before the prefetched data is needed.

Steps to reproduce

  1. Deploy an EmDash site to Cloudflare Workers using Postgres through Hyperdrive:
import { hyperdrive } from "@emdash-cms/cloudflare";

const database = hyperdrive({
  binding: "HYPERDRIVE_UNCACHED",
  cachedBinding: "HYPERDRIVE_CACHED",
});
  1. Configure both Hyperdrive bindings and enable nodejs_compat:
{
  "compatibility_date": "2026-06-24",
  "compatibility_flags": ["nodejs_compat"],
  "hyperdrive": [
    {
      "binding": "HYPERDRIVE_UNCACHED",
      "id": "<uncached-hyperdrive-id>"
    },
    {
      "binding": "HYPERDRIVE_CACHED",
      "id": "<cached-hyperdrive-id>"
    }
  ]
}
  1. Ensure public HTML requests exercise EmDash layout data such as menus, widget areas, taxonomy terms, or site settings.

  2. Send repeated uncached public HTML requests to existing and missing paths. The issue has been observed both with and without a trailing slash.

  3. Optionally, make the race easier to trigger by adding application middleware that immediately returns a bodyless redirect after EmDash has started handling a public HTML request. For example, canonicalize a missing trailing slash:

const trailingSlash = defineMiddleware((context, next) => {
  if (!context.url.pathname.endsWith("/")) {
    return context.redirect(`${context.url.pathname}/`, 301);
  }
  return next();
});
  1. Send repeated requests with HTML preferred. Test both canonical paths and the optional early-redirect path:
curl -o /dev/null -D - \
  -H 'Accept: text/html,application/xhtml+xml' \
  'https://example.com/existing-path/'

curl -o /dev/null -D - \
  -H 'Accept: text/html,application/xhtml+xml' \
  'https://example.com/tag/example'
  1. Inspect Workers Observability. Affected invocations have outcome: "ok" and low CPU time, but remain open for approximately 30 seconds before emitting the waitUntil() cancellation warning. Response status varies and is not limited to 301.

  2. To confirm pool state, temporarily inspect pool.totalCount, pool.idleCount, and pool.waitingCount immediately before db.destroy() and again after five seconds. In the observed production samples, all clients are non-idle at both snapshots.

The issue is timing-dependent and may require repeated requests or real Hyperdrive latency to reproduce. It has occurred frequently in production, approximately once every one to three minutes during the observation window.

Environment

  • emdash version: 0.29.0
  • @emdash-cms/cloudflare version: 0.29.0
  • pg version: 8.22.0
  • Astro version: 7.0.6
  • Node.js version: 22.22.3 locally
  • Runtime: Cloudflare Workers, stateless execution model
  • Workers compatibility date: 2026-06-24
  • Workers compatibility flags: nodejs_compat
  • Database: PlanetScale Postgres through Cloudflare Hyperdrive
  • Database adapter: split cached/uncached Hyperdrive bindings
  • OS: macOS locally

Logs / error output

Representative pool diagnostic for a bodyless `301`:


{
  "event": "emdash_hyperdrive_pool_close_slow",
  "binding": "HYPERDRIVE_CACHED",
  "elapsedMs": 5000,
  "initial": {
    "total": 2,
    "idle": 0,
    "nonIdle": 2,
    "waiting": 0
  },
  "current": {
    "total": 2,
    "idle": 0,
    "nonIdle": 2,
    "waiting": 0
  },
  "request": {
    "method": "GET",
    "path": "/tag/cli"
  },
  "requestId": "a1d9233189532773-LAX"
}


Matching invocation result:


{
  "request": {
    "method": "GET",
    "path": "/tag/cli",
    "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"
  },
  "response": {
    "status": 301
  },
  "outcome": "ok",
  "cpuTimeMs": 6,
  "wallTimeMs": 29997,
  "requestId": "a1d9233189532773-LAX"
}


Matching runtime warning:


waitUntil() tasks did not complete within the allowed time after invocation end and have been cancelled. See: https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil


Additional sampled pool states:


{
  "request": "HEAD /images/magnifier.svg",
  "initial": { "total": 1, "idle": 0, "nonIdle": 1, "waiting": 0 },
  "current": { "total": 1, "idle": 0, "nonIdle": 1, "waiting": 0 }
}



{
  "request": "GET /de-de/making-ai-search-smarter/",
  "initial": { "total": 3, "idle": 0, "nonIdle": 3, "waiting": 0 },
  "current": { "total": 3, "idle": 0, "nonIdle": 3, "waiting": 0 }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions