Skip to content

cachedFetchJson inflight Map has no timeout — hung fetcher poisons key for isolate lifetime #3539

@koala73

Description

@koala73

Summary

server/_shared/redis.ts:251-289:

const inflight = new Map<string, Promise<unknown>>();

export async function cachedFetchJson<T>(...) {
  ...
  const promise = fetcher()
    .then(...)
    .catch(...)
    .finally(() => {
      inflight.delete(key);   // <-- only release path
    });
  inflight.set(key, promise);
  return promise;
}

The inflight entry only deletes in .finally(). If fetcher() truly never settles (no internal timeout, no AbortController) the entry persists forever in the Vercel isolate. Every subsequent caller for that key gets handed the same unresolved promise.

This is mostly mitigated when fetchers are well-behaved (use cachedFetchJson wrappers that abort on UPSTREAM_TIMEOUT_MS), but cachedFetchJson itself doesn't enforce one — it trusts the caller. A new caller forgetting the timeout creates a permanent hot-key trap.

Likely fix

Wrap with a timeout race inside cachedFetchJson so the inflight entry is guaranteed to settle:

const FETCHER_TIMEOUT_MS = 30_000;

const promise = Promise.race([
  fetcher(),
  new Promise<T | null>((_, reject) =>
    setTimeout(() => reject(new Error(`cachedFetchJson timeout for ${key}`)), FETCHER_TIMEOUT_MS)
  ),
])
  .then(...)
  .catch(...)
  .finally(() => { inflight.delete(key); });

Treats the cache layer as the last line of defense even if a fetcher misbehaves.

Severity

P1 — latent until a fetcher misbehaves, then permanent for the isolate. Cheap insurance.

Source

Manual code review (deepseek perf list, validated 2026-05-01).

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: APIBackend API, sidecar, keysbugSomething isn't workingperformancePerformance optimization

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions