Skip to content

AbortSignal listener leak in proxy.post() hangs on larger response payloads in long-lived processes #146

Description

@Gdewilde

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    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