Skip to content

Memory leak under Bun: NovuCustomHook.afterSuccess clones every response twice #118

@kilbot

Description

@kilbot

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    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