Describe the bug
Three overrides await I/O with no timeout and no abort signal, inline in the request path. If the call is accepted but never answered, the promise never settles and the invocation is held open with no upper bound.
-
r2IncrementalCache — api/overrides/incremental-cache/r2-incremental-cache.js
const r2Object = await r2.get(this.getR2Key(key, cacheType)); // :23
await r2.put(this.getR2Key(key, cacheType), JSON.stringify(value)); // :42
Reached from 12 call sites in @opennextjs/aws core, including the composable cache behind 'use cache' (adapters/composable-cache.js:19,76).
-
doQueue — api/overrides/queue/do-queue.js
await stub.revalidate({ ...msg });
No timeout and no catch. Awaited inline at @opennextjs/aws/dist/core/routing/util.js:291 on the stale-revalidation path. The try/catch there cannot help a promise that never settles.
-
fetchProxy — @opennextjs/aws/dist/overrides/proxyExternalRequest/fetch.js:8
const response = await fetch(url, { method, headers, body });
No AbortSignal. Awaited at core/requestHandler.js:75 for external rewrites.
Amplified when a page has no <Suspense> boundary: one stalled await holds the entire response.
Production evidence
Cloudflare Workers, Next 16, ~1.3M invocations/day, measured over four days with all three paths wrapped downstream in a 15s budget.
- The budget fires. R2
get and set exceed 15s in sustained multi-hour periods on healthy infrastructure. Not attributable to the Cloudflare R2/DO incident in the same window — that lasted 41 minutes; the timeouts span hours before and after it.
- Healthy-window latency, max rather than percentile (our sampling is slow-biased by construction):
set 2,089ms, get 3,219ms. Worst completed write observed: 11,046ms — inside a 15s budget, therefore invisible without one.
- A timeout bounds the hang but does not fix it. A render released at ~16s has already lost the user; of the affected invocations roughly 1 in 6 returned a response at all.
- Writes outnumber reads ~10:1 — 60,778
set against 5,890 get in six hours. Every 'use cache' miss writes an entry, and on a cold keyspace (8,420 distinct URLs across 12,779 requests) most requests miss all of them. Sizing incrementalCache from read behaviour understates the problem.
- What removed the user-visible stall was moving
set off the critical path. composable-cache.js:73 awaits it and a cold render issues ~8. Deferring them (timeout kept inside the deferred promise): exceededMemory 4.60 → 0.08 per 10k, requests >30s 5-in-1747 → 0, waitUntil cancellations → 0.
Scope note: our own exceededMemory kills were application-side, not this defect. An earlier revision of this report attributed them here; that was wrong. This issue is about the missing bound, which stands independently.
proxyExternalRequest cannot be fixed downstream
defineCloudflareConfig hardcodes proxyExternalRequest: "fetch" and exposes no option, and cli/build/utils/ensure-cf-config.js validates it by string equality:
dftUseFetchProxy: config.default?.override?.proxyExternalRequest === "fetch",
mwUseFetchProxy: mwConfig?.override?.proxyExternalRequest === "fetch",
A wrapped override fails validation and the build aborts. The cache and queue can be wrapped by users; this one cannot.
Steps to reproduce
The adapters can be driven directly; no Cloudflare account needed. Bindings resolve through globalThis[Symbol.for('__cloudflare-context__')].
import { describe, expect, it, vi, beforeEach } from 'vitest'
const r2 = { get: vi.fn(), put: vi.fn(), delete: vi.fn() }
const doStub = { revalidate: vi.fn() }
beforeEach(() => {
vi.useFakeTimers()
;(globalThis as any)[Symbol.for('__cloudflare-context__')] = {
env: {
NEXT_INC_CACHE_R2_BUCKET: r2,
NEXT_CACHE_DO_QUEUE: { idFromName: () => 'id', get: () => doStub },
},
}
})
/** Models a call that is accepted but never answered. */
const NEVER = () => new Promise<never>(() => {})
const settledWithin = async (p: Promise<unknown>, ms: number) => {
let settled = false
void p.then(() => (settled = true), () => (settled = true))
await vi.advanceTimersByTimeAsync(ms)
return settled
}
it('r2IncrementalCache.get never settles', async () => {
const { default: cache } = await import(
'@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache'
)
r2.get.mockImplementation(NEVER)
const p = cache.get('some-key', 'composable')
p.catch(() => {})
// One hour of simulated time.
expect(await settledWithin(p, 60 * 60 * 1000)).toBe(false)
expect(r2.get).toHaveBeenCalledTimes(1) // proves it reached R2
})
it('doQueue.send never settles', async () => {
const { default: queue } = await import(
'@opennextjs/cloudflare/overrides/queue/do-queue'
)
doStub.revalidate.mockImplementation(NEVER)
const p = queue.send({
MessageBody: { host: 'example.com', url: '/x' },
MessageDeduplicationId: 'd',
MessageGroupId: 'g',
} as never)
p.catch(() => {})
expect(await settledWithin(p, 60 * 60 * 1000)).toBe(false)
})
Both pass — i.e. neither call has any upper bound.
Note: exercising these under Vitest needs test.server.deps.inline: [/@opennextjs\/aws/], because @opennextjs/aws ships extensionless relative imports (e.g. from "../../utils/stream" in overrides/proxyExternalRequest/fetch.js) that Node's ESM resolver rejects. They resolve in production only because the build bundles with esbuild. That may be worth fixing separately.
Expected behavior
Override I/O should have an upper bound, so a stalled dependency degrades instead of holding the invocation open:
incrementalCache.get → treat as a cache miss (what its own catch already does on an R2 error)
incrementalCache.set / delete → log and resolve (already swallowed on error)
queue.send → log and resolve; the message is a revalidation hint
proxyExternalRequest.proxy → reject, so requestHandler.js:75 serves its /500 rewrite. fetch takes an AbortSignal, so this one can cancel rather than abandon
A configurable budget (e.g. a timeouts option on defineCloudflareConfig) would be ideal; a generous default of 10–15s already converts an unbounded hang into a bounded failure.
Happy to open a PR.
@opennextjs/cloudflare version
1.20.2
Wrangler version
4.123.0
next info output
Operating System:
Platform: darwin
Arch: arm64
Binaries:
Node: 26.2.0
Relevant Packages:
next: 16.3.1
@opennextjs/cloudflare: 1.20.2
wrangler: 4.123.0
Additional context
Our downstream workaround wraps the stock adapters in a Promise.race against a timer (delegating rather than reimplementing, so cache-key computation stays upstream's concern), cleared in finally so a fast path leaves no pending timer.
Testing note: exercising these under Vitest needs test.server.deps.inline: [/@opennextjs\/aws/], because @opennextjs/aws ships extensionless relative imports (e.g. from "../../utils/stream" in overrides/proxyExternalRequest/fetch.js) that Node's ESM resolver rejects. They resolve in production only because the build bundles with esbuild. Possibly worth fixing separately.
Describe the bug
Three overrides
awaitI/O with no timeout and no abort signal, inline in the request path. If the call is accepted but never answered, the promise never settles and the invocation is held open with no upper bound.r2IncrementalCache—api/overrides/incremental-cache/r2-incremental-cache.jsReached from 12 call sites in
@opennextjs/awscore, including the composable cache behind'use cache'(adapters/composable-cache.js:19,76).doQueue—api/overrides/queue/do-queue.jsNo timeout and no catch. Awaited inline at
@opennextjs/aws/dist/core/routing/util.js:291on the stale-revalidation path. Thetry/catchthere cannot help a promise that never settles.fetchProxy—@opennextjs/aws/dist/overrides/proxyExternalRequest/fetch.js:8No
AbortSignal. Awaited atcore/requestHandler.js:75for external rewrites.Amplified when a page has no
<Suspense>boundary: one stalled await holds the entire response.Production evidence
Cloudflare Workers, Next 16, ~1.3M invocations/day, measured over four days with all three paths wrapped downstream in a 15s budget.
getandsetexceed 15s in sustained multi-hour periods on healthy infrastructure. Not attributable to the Cloudflare R2/DO incident in the same window — that lasted 41 minutes; the timeouts span hours before and after it.set2,089ms,get3,219ms. Worst completed write observed: 11,046ms — inside a 15s budget, therefore invisible without one.setagainst 5,890getin six hours. Every'use cache'miss writes an entry, and on a cold keyspace (8,420 distinct URLs across 12,779 requests) most requests miss all of them. SizingincrementalCachefrom read behaviour understates the problem.setoff the critical path.composable-cache.js:73awaits it and a cold render issues ~8. Deferring them (timeout kept inside the deferred promise):exceededMemory4.60 → 0.08 per 10k, requests >30s 5-in-1747 → 0,waitUntilcancellations → 0.Scope note: our own
exceededMemorykills were application-side, not this defect. An earlier revision of this report attributed them here; that was wrong. This issue is about the missing bound, which stands independently.proxyExternalRequestcannot be fixed downstreamdefineCloudflareConfighardcodesproxyExternalRequest: "fetch"and exposes no option, andcli/build/utils/ensure-cf-config.jsvalidates it by string equality:A wrapped override fails validation and the build aborts. The cache and queue can be wrapped by users; this one cannot.
Steps to reproduce
The adapters can be driven directly; no Cloudflare account needed. Bindings resolve through
globalThis[Symbol.for('__cloudflare-context__')].Both pass — i.e. neither call has any upper bound.
Note: exercising these under Vitest needs
test.server.deps.inline: [/@opennextjs\/aws/], because@opennextjs/awsships extensionless relative imports (e.g.from "../../utils/stream"inoverrides/proxyExternalRequest/fetch.js) that Node's ESM resolver rejects. They resolve in production only because the build bundles with esbuild. That may be worth fixing separately.Expected behavior
Override I/O should have an upper bound, so a stalled dependency degrades instead of holding the invocation open:
incrementalCache.get→ treat as a cache miss (what its owncatchalready does on an R2 error)incrementalCache.set/delete→ log and resolve (already swallowed on error)queue.send→ log and resolve; the message is a revalidation hintproxyExternalRequest.proxy→ reject, sorequestHandler.js:75serves its/500rewrite.fetchtakes anAbortSignal, so this one can cancel rather than abandonA configurable budget (e.g. a
timeoutsoption ondefineCloudflareConfig) would be ideal; a generous default of 10–15s already converts an unbounded hang into a bounded failure.Happy to open a PR.
@opennextjs/cloudflare version
1.20.2
Wrangler version
4.123.0
next info output
Additional context
Our downstream workaround wraps the stock adapters in a
Promise.raceagainst a timer (delegating rather than reimplementing, so cache-key computation stays upstream's concern), cleared infinallyso a fast path leaves no pending timer.Testing note: exercising these under Vitest needs
test.server.deps.inline: [/@opennextjs\/aws/], because@opennextjs/awsships extensionless relative imports (e.g.from "../../utils/stream"inoverrides/proxyExternalRequest/fetch.js) that Node's ESM resolver rejects. They resolve in production only because the build bundles with esbuild. Possibly worth fixing separately.