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:
- A generated API method specifies format: 'json';
- The endpoint returns a non-trivial response body;
- The API is called concurrently;
- The process runs for a long time;
- 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.
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
Generated code
The generated client contains code similar to:
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:
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:
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:
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:
The key change is:
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.