Skip to content

[BUG] incrementalCache, queue and proxyExternalRequest await I/O with no timeout or abort signal #1350

Description

@isaacrowntree

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.

  1. r2IncrementalCacheapi/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).

  2. doQueueapi/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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions