Summary
NovuCustomHook.afterSuccess calls response.clone() twice on every successful response. Under the Bun runtime, cloning a fetch() Response and consuming the clones leaks native (external) memory that GC never reclaims. In a long-running server this grows RSS in proportion to API traffic until the process exhausts memory / the event loop wedges. Under Node.js the same code is fine (GC reclaims the buffers), so this is Bun-specific but triggered by an SDK anti-pattern.
Where
src/hooks/novu-custom-hook.ts (hand-written hook, not regenerated):
async afterSuccess(_hookCtx, response) {
const responseAsText = await response.clone().text(); // clone #1
const contentType = response.headers.get('content-type') || '';
if (!responseAsText || responseAsText == '' || contentType.includes('text/html')) {
return response;
}
const jsonResponse = await response.clone().json(); // clone #2
...
return response; // original left unconsumed
}
The body is cloned twice (and read as text, then re-parsed as JSON), and in the common path the original Response is returned unconsumed. Per the Fetch standard, a streaming body should be consumed once; cloning to re-read — and leaving a tee branch unconsumed — forces the runtime to buffer the body. Bun does not release those buffers.
Reproduction
Driving the real SDK (@novu/api@3.16.0) against a local mock returning a ~40 KB JSON body, new Novu().subscribers.retrieve(...) in a loop, forcing GC each sample:
| calls |
Bun 1.3.14 (current SDK) |
Node v24 (current SDK) |
| 2000 |
280 MB |
external peaks then reclaimed |
| 6000 |
414 MB |
bounded |
| 10000 |
522 MB — linear, unbounded |
external returns to ~3.6 MB after GC |
Isolated A/B of just the hook's pattern under Bun: the double-clone() path climbs 28 → 531 MB over 20k requests and never reclaims; reading the body once (no clone) stays flat at ~53 MB.
Suggested fix
Read the body exactly once and derive both representations from it — no clone():
async afterSuccess(_hookCtx, response) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('text/html')) return response;
const text = await response.text();
const repack = (body) => new Response(body, {
status: response.status, statusText: response.statusText, headers: response.headers,
});
if (!text) return repack(null);
let json;
try { json = JSON.parse(text); } catch { return repack(text); }
if (json && typeof json === 'object' && Object.keys(json).length === 1 && 'data' in json) {
return repack(JSON.stringify(json.data));
}
return repack(text);
}
Behaviour is identical (text/html and non-envelope JSON pass through; single-key { data } envelopes are still unwrapped), but the body is consumed once, which both fixes the Bun leak and follows the single-consumption best practice. We're running this as a bun patch in production and verified it turns the unbounded growth into a flat plateau.
Environment
@novu/api 3.16.0 (hook is identical on main as of this writing)
- Bun 1.3.14 (leaks); Node v24.14.0 (does not)
- Long-running Elysia/Bun HTTP server
Summary
NovuCustomHook.afterSuccesscallsresponse.clone()twice on every successful response. Under the Bun runtime, cloning afetch()Responseand consuming the clones leaks native (external) memory that GC never reclaims. In a long-running server this grows RSS in proportion to API traffic until the process exhausts memory / the event loop wedges. Under Node.js the same code is fine (GC reclaims the buffers), so this is Bun-specific but triggered by an SDK anti-pattern.Where
src/hooks/novu-custom-hook.ts(hand-written hook, not regenerated):The body is cloned twice (and read as text, then re-parsed as JSON), and in the common path the original
Responseis returned unconsumed. Per the Fetch standard, a streaming body should be consumed once; cloning to re-read — and leaving a tee branch unconsumed — forces the runtime to buffer the body. Bun does not release those buffers.Reproduction
Driving the real SDK (
@novu/api@3.16.0) against a local mock returning a ~40 KB JSON body,new Novu().subscribers.retrieve(...)in a loop, forcing GC each sample:Isolated A/B of just the hook's pattern under Bun: the double-
clone()path climbs 28 → 531 MB over 20k requests and never reclaims; reading the body once (no clone) stays flat at ~53 MB.Suggested fix
Read the body exactly once and derive both representations from it — no
clone():Behaviour is identical (text/html and non-envelope JSON pass through; single-key
{ data }envelopes are still unwrapped), but the body is consumed once, which both fixes the Bun leak and follows the single-consumption best practice. We're running this as abun patchin production and verified it turns the unbounded growth into a flat plateau.Environment
@novu/api3.16.0 (hook is identical onmainas of this writing)