Skip to content

Potential memory leak in generated HttpClient when parsing responses with Response.clone() #1827

Description

@frei-x

Summary

The generated HttpClient appears to retain an unconsumed Response body when a response format is specified, such as format: 'json'.

This can cause continuous growth in Node.js external / ArrayBuffer memory and RSS in long-running server-side applications.

Environment

  • swagger-typescript-api: 13.1.3 or 13.12.6
  • Next.js: 15.x
  • Node.js: 18.x
  • Runtime: Kubernetes, long-running Node.js server
  • Fetch implementation: Node.js native fetch / Undici
  • Response format: json, and potentially blob / arrayBuffer

Generated code

The generated client contains code similar to:

).then(async (response) => {
  const r = response.clone() as HttpResponse<T, E>;

  r.data = null as unknown as T;
  r.error = null as unknown as E;

  const data = !responseFormat
    ? r
    : await response[responseFormat]()
        .then((data) => {
          if (r.ok) {
            r.data = data;
          } else {
            r.error = data;
          }

          return r;
        })
        .catch((e) => {
          r.error = e;
          return r;
        });

  if (!response.ok) {
    throw data;
  }

  return data;
});

When responseFormat is set to 'json', the original response body is consumed:

await response.json();

However, the returned object is the cloned response:

const r = response.clone();
return r;

The cloned response body is not consumed or explicitly cancelled.

Suspected lifecycle

The body lifecycle appears to be:

original Response
├── response.json()
│ └── consumed
└── response.clone()
└── returned to caller, but body is not consumed or cancelled

Response.clone() creates two body branches. If only one branch is consumed, the other branch may retain queued data until it is explicitly consumed, cancelled, or
garbage-collected.

In a server-side application, this may retain:

  • ReadableStream objects;
  • Undici response body state;
  • Buffer / ArrayBuffer data;
  • native/external memory;
  • queued response chunks.

Observed behavior

We have a Next.js server-side route that makes multiple upstream API requests per request.

During a load test with approximately 50 concurrent requests:

  • RSS continuously increases;
  • memory does not return to the original level after traffic stops;
  • the process eventually reaches the container memory limit and is OOM-killed;
  • the behavior repeats after the pod restarts.

The memory metrics show a disproportionate amount of external and ArrayBuffer memory:

rss ≈ 3.64 GB
heapTotal ≈ 0.89 GB
heapUsed ≈ 0.79 GB
external ≈ 2.30 GB
arrayBuffers ≈ 2.29 GB

This suggests that the problem may not be a traditional V8 JavaScript heap leak. The growth is more consistent with response-body buffering, Undici stream state,
Buffer/ArrayBuffer retention, or native memory retained by the HTTP implementation.

Reproduction characteristics

The issue becomes more visible when:

  1. A generated API method specifies format: 'json';
  2. The endpoint returns a non-trivial response body;
  3. The API is called concurrently;
  4. The process runs for a long time;
  5. The returned HttpResponse object is not explicitly consumed again by the caller.

For example:

const response = await api.someEndpoint({
format: 'json',
});

console.log(response.data);

The caller only uses response.data. It does not consume or cancel response.body.

Expected behavior

When a response format is specified, the generated client should consume the same response body that is returned, or otherwise ensure that any cloned body is
consumed or cancelled.

There should not be an unconsumed body branch left behind by default.

Possible fix

The clone appears to be unnecessary. The client could parse the original response directly:

).then(async (response) => {
   const r = response as HttpResponse<T, E>;

   r.data = null as unknown as T;
   r.error = null as unknown as E;

   const data = !responseFormat
     ? r
     : await response[responseFormat]()
         .then((data) => {
           if (r.ok) {
             r.data = data;
           } else {
             r.error = data;
           }

           return r;
         })
         .catch((e) => {
           r.error = e;
           return r;
         });

   if (!response.ok) {
     throw data;
   }

   return data;
 });

The key change is:

  - const r = response.clone() as HttpResponse<T, E>;
  + const r = response as HttpResponse<T, E>;

  - await response[responseFormat]()
  + await response[responseFormat]()

In other words, the response should be parsed directly instead of cloning it.

Another possible implementation would be to explicitly cancel the unused branch,although avoiding Response.clone() entirely would be simpler and less error-prone.

Additional concern

A similar issue may also occur in the following alternative implementation:

const r = response as HttpResponse<T, E>;
const responseToParse = responseFormat ? response.clone() : response;

await responseToParseresponseFormat;

return r;

In this version, the clone is consumed but the original response returned as r remains unconsumed. Therefore, simply moving response.clone() to another variable
may not resolve the underlying problem.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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