TL;DR
Calling apideck.proxy.post(...) repeatedly in a long-lived Node process (e.g. a Next.js dev server, a Vercel Function with Fluid Compute, a CLI worker) accumulates AbortSignal listeners until Node fires MaxListenersExceededWarning. After accumulation, individual apideck.proxy.post(...) calls hang indefinitely — specifically on endpoints that return larger payloads. Calls to smaller-payload endpoints on the same SDK instance keep working.
A direct fetch() to https://unify.apideck.com/proxy with an explicit per-call AbortSignal.timeout(...) does not exhibit the bug.
Version
@apideck/unify@0.38.2, Node 22, Next.js 16 App Router.
Reproduction
Long-lived process making many apideck.proxy.post(...) calls (mixing endpoints):
import { Apideck } from "@apideck/unify";
const apideck = new Apideck({
apiKey: process.env.APIDECK_API_KEY!,
appId: process.env.APIDECK_APP_ID!,
consumerId: "demo-user",
});
// In a long-running server route hit repeatedly:
await apideck.proxy.post({
serviceId: "hubspot",
downstreamUrl: "https://api.hubapi.com/crm/v3/objects/contacts/batch/read",
requestBody: new Blob([JSON.stringify({ inputs: ids.map(id => ({ id })), properties: ["email"] })], { type: "application/json" }),
});
// After a few hundred such calls over the life of the process:
// (node:96368) MaxListenersExceededWarning: Possible EventTarget memory leak detected.
// 11 abort listeners added to [AbortSignal]. MaxListeners is 10.
//
// Subsequent calls to larger-payload endpoints like:
await apideck.proxy.post({
serviceId: "hubspot",
downstreamUrl: "https://api.hubapi.com/crm/v3/objects/emails/batch/read",
requestBody: new Blob([JSON.stringify({
inputs: ids.map(id => ({ id })),
properties: ["hs_email_subject","hs_email_text","hs_email_html","hs_email_direction","hs_timestamp","hs_email_sender_email","hs_email_to_email","hs_email_html","hs_body_preview","hs_email_cc_email","hs_email_headers","hs_email_open_count","hs_email_click_count","hs_email_first_open_date"],
})], { type: "application/json" }),
});
// → hangs indefinitely. Same instance, same auth, same upstream, smaller endpoints still return normally.
The hang is at the SDK layer, not the upstream:
- Direct probe of the same endpoint via
fetch() returns 26 emails in ~1.2s
- Other SDK endpoints on the same instance keep returning in 200-300ms
- A fresh Node process with a fresh
Apideck instance also returns the call in ~1.2s
Suspected cause
The SDK appears to be attaching abort listeners to a shared AbortSignal (or a controller whose signal is reused) for every fetch the SDK issues, without removing them when each call completes. Once the listener count exceeds Node's default EventEmitter.defaultMaxListeners = 10, Node logs the warning and (we suspect) the SDK's internal fetch behavior degrades for response handling — specifically on larger payloads where response stream consumption is more sensitive.
The fact that smaller payloads keep working while larger ones hang suggests the issue is in how the SDK consumes the response stream (or in the wiring between the response's signal and the SDK's parent signal), not in initiating the request.
Impact
- Vercel Functions with Fluid Compute reuse function instances across requests, so the leak compounds across user requests and eventually wedges a function instance until the platform recycles it.
- Long-running workers / dev servers wedge after a few hundred calls. A
MaxListenersExceededWarning is the only signal — easy to dismiss as noise.
- CI / test suites that exercise many SDK calls in one process may flake intermittently with timeouts.
Workaround we shipped
Replacing apideck.proxy.post(...) with a direct fetch("https://unify.apideck.com/proxy", { ... }) using the documented header set (Authorization, x-apideck-app-id, x-apideck-consumer-id, x-apideck-service-id, x-apideck-downstream-url) and a per-call AbortSignal.timeout(20_000) fully resolves the issue. Each call gets a fresh AbortController, so no accumulation. See apideck-io/gtm-pilot#198 for the change.
Suggested fix
Audit the SDK's internal fetch wrapper for signal.addEventListener('abort', ...) calls and ensure each one is paired with a corresponding removeEventListener in the call's finally block (or use AbortSignal.any([...]) + per-call AbortControllers so listeners go out of scope with the call).
A setMaxListeners(N) workaround on the shared signal would silence the warning but wouldn't fix the underlying leak — the response-handling degradation on larger payloads would still happen.
Happy to share more reproduction context if useful.
TL;DR
Calling
apideck.proxy.post(...)repeatedly in a long-lived Node process (e.g. a Next.js dev server, a Vercel Function with Fluid Compute, a CLI worker) accumulatesAbortSignallisteners until Node firesMaxListenersExceededWarning. After accumulation, individualapideck.proxy.post(...)calls hang indefinitely — specifically on endpoints that return larger payloads. Calls to smaller-payload endpoints on the same SDK instance keep working.A direct
fetch()tohttps://unify.apideck.com/proxywith an explicit per-callAbortSignal.timeout(...)does not exhibit the bug.Version
@apideck/unify@0.38.2, Node 22, Next.js 16 App Router.Reproduction
Long-lived process making many
apideck.proxy.post(...)calls (mixing endpoints):The hang is at the SDK layer, not the upstream:
fetch()returns 26 emails in ~1.2sApideckinstance also returns the call in ~1.2sSuspected cause
The SDK appears to be attaching abort listeners to a shared
AbortSignal(or a controller whose signal is reused) for every fetch the SDK issues, without removing them when each call completes. Once the listener count exceeds Node's defaultEventEmitter.defaultMaxListeners = 10, Node logs the warning and (we suspect) the SDK's internal fetch behavior degrades for response handling — specifically on larger payloads where response stream consumption is more sensitive.The fact that smaller payloads keep working while larger ones hang suggests the issue is in how the SDK consumes the response stream (or in the wiring between the response's signal and the SDK's parent signal), not in initiating the request.
Impact
MaxListenersExceededWarningis the only signal — easy to dismiss as noise.Workaround we shipped
Replacing
apideck.proxy.post(...)with a directfetch("https://unify.apideck.com/proxy", { ... })using the documented header set (Authorization,x-apideck-app-id,x-apideck-consumer-id,x-apideck-service-id,x-apideck-downstream-url) and a per-callAbortSignal.timeout(20_000)fully resolves the issue. Each call gets a fresh AbortController, so no accumulation. See apideck-io/gtm-pilot#198 for the change.Suggested fix
Audit the SDK's internal fetch wrapper for
signal.addEventListener('abort', ...)calls and ensure each one is paired with a correspondingremoveEventListenerin the call'sfinallyblock (or useAbortSignal.any([...])+ per-call AbortControllers so listeners go out of scope with the call).A
setMaxListeners(N)workaround on the shared signal would silence the warning but wouldn't fix the underlying leak — the response-handling degradation on larger payloads would still happen.Happy to share more reproduction context if useful.