Skip to content

Memory (RSS) in Bun Spawned Child Process Grows Slowly, Even When Idle #21560

Open

Description

@acoBOYZ

What version of Bun is running?

1.2.19

What platform is your computer?

• Linux (Ubuntu 22.04) • Also tested on macOS (M1 Pro)

What steps can reproduce the bug?

What’s happening?

I’m running a Bun child process with basically no logic inside it—just an interval that prints memory usage and sends a small meta object back to the parent every few seconds (via IPC).

At first, memory (RSS) in the child is around 110–115MB. Over the course of several hours, it drifts up to 140–150MB. This is without doing any real work:
• heapUsed and heapTotal stay flat,
• external is stable,
• Only RSS climbs (and never shrinks, even after forced GC).

This is not a parent memory leak, not a log buffer problem, and not a flood of IPC messages (the parent is always reading quickly).
GC doesn’t help—calling global.gc() has no effect on RSS.

How to reproduce (simple version):

bun-child-mem-test.ts:

setInterval(() => {
  const mem = process.memoryUsage();
  console.log(`[MEM] heapUsed=${(mem.heapUsed/1024/1024).toFixed(1)}MB | heapTotal=${(mem.heapTotal/1024/1024).toFixed(1)}MB | rss=${(mem.rss/1024/1024).toFixed(1)}MB | external=${(mem.external/1024/1024).toFixed(1)}MB`);
  global.gc?.();
}, 3000);

Run with:
bun --optimize --gc --expose-gc bun-child-mem-test.ts

What is the expected behavior?

• I’d expect RSS to stay relatively flat for an idle child process, or at least drop after GC if there’s nothing to collect.
• Node.js keeps its RSS low for a similar idle process, so this behavior seems unique to Bun.

What do you see instead?

• On Linux, RSS in the child process starts low (~110MB) but keeps slowly rising up to ~145MB and stays there, even though the JS heap numbers are stable.
• On macOS M1 Pro, the RSS is noticeably lower (almost half), but still rises a bit.
• This is not related to file descriptors or open files (checked with lsof—nothing weird).
• If you spawn multiple children, you see the same thing for each process (not a parent-side problem).

Additional information

Why it matters

•	For apps that need to spawn a lot of child processes (or keep them running for days), this leads to unnecessary RAM usage over time and could be a problem in production.
•	I’m not seeing this with Node.js, so it seems like a Bun-specific behavior.

Screenshots / logs

Here are some logs from a single child running overnight:
[01-08-2025 23:25:56.423] [MEM] heapUsed=32.8MB | heapTotal=9.6MB | rss=115.5MB | external=24.4MB
[02-08-2025 06:10:57.038] [MEM] heapUsed=29.9MB | heapTotal=9.2MB | rss=145.2MB | external=22.3MB
(and similar steady numbers all night, except for RSS which rises and never falls)

What I’ve already tried

• Forcing global.gc()
• Running with and without IPC, draining all stdio, no extra modules
• Same test on different OSes and hardware

TL;DR

• Child process just sends IPC meta and logs memory, but RSS keeps creeping up over hours, never drops, even if JS heap is totally flat.
• Not a userland leak, not a parent-side issue, not fixed by GC or different allocators.
• Seems unique to Bun.

Would love to know if this is expected, a known quirk of Bun/mimalloc, or maybe a deeper issue. Happy to help test more, or provide more logs if needed.

Thank you!

Activity

  1. Jarred-Sumner commented on Aug 2, 2025

    @Jarred-Sumner
    Collaborator

    Would love to help but this scenario is unfortunately over-simplified for us to take any action, as it does not demonstrate unbounded memory growth:

    setInterval(() => {
      const mem = process.memoryUsage();
      console.log(`[MEM] heapUsed=${(mem.heapUsed/1024/1024).toFixed(1)}MB | heapTotal=${(mem.heapTotal/1024/1024).toFixed(1)}MB | rss=${(mem.rss/1024/1024).toFixed(1)}MB | external=${(mem.external/1024/1024).toFixed(1)}MB`);
      global.gc?.();
    }, 3000);
    
    ❯ bun a.js
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=20.8MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=21.5MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.3MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.3MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.3MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.3MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.3MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.3MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.3MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.3MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.3MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    [MEM] heapUsed=0.3MB | heapTotal=0.6MB | rss=18.4MB | external=0.1MB
    
    took 6m25s
    

    Also note that --optimize and --gc don't do anything in Bun (we don't error because some people may pass flags to Bun meant for Node)

  2. added
    performanceAn issue with performance
    linuxAn issue that occurs on Linux
    and removed
    linuxAn issue that occurs on Linux
    on Aug 2, 2025
  3. acoBOYZ commented on Aug 2, 2025

    @acoBOYZ
    Author

    Hey, thanks for the detailed feedback and for checking my previous test!

    To clarify:
    I ran my child/parent setup for 6+ hours on Linux before, and the memory increase is what I was seeing in my production scenario, where the child process does real work and holds some heap (which is simulated here by allocating a static buffer).

    Here’s a set of minimal test scripts that gets as close as possible to my real production structure (class-based, static heap, IPC via custom pipe-handler, etc):

    logger.js

    import { pino } from 'pino';
    export const logger = (debug, level) => {
      if (!debug) {
        return pino({ level: 'silent' });
      }
      return pino({
        level: level || 'debug',
        base: undefined,
        transport: {
          target: 'pino-pretty',
          options: {
            colorize: true,
            singleLine: true,
            translateTime: 'dd-mm-yyyy HH:MM:ss.l',
            ignore: 'pid,hostname',
          },
        },
      });
    };

    pipe-handler.js

    import os from 'os';
    import { promise as fastqPromise } from "fastq";
    export class PipeHandler {
      constructor() {
        this.callbacks = {};
        this.queue = fastqPromise(this, this.process.bind(this), os.cpus().length);
      }
      on(type, callback) {
        if (!this.callbacks[type]) {
          this.callbacks[type] = [];
        }
        this.callbacks[type].push(callback);
      }
      off(type, callback) {
        const callbacks = this.callbacks[type];
        if (callbacks) {
          const index = callbacks.indexOf(callback);
          if (index !== -1) {
            callbacks.splice(index, 1);
          }
        }
      }
      once(type) {
        return new Promise((resolve) => {
          const handler = (data) => {
            this.off(type, handler);
            resolve(data);
          };
          this.on(type, handler);
        });
      }
      emit(type, data) {
        this.queue.push({ type, data });
      }
      async process({ type, data, }) {
        const callbacks = this.callbacks[type];
        if (callbacks) {
          for (const callback of callbacks) {
            await callback(data);
          }
        }
      }
      routeMessage(message) {
        const { type, data } = message;
        const processedData = typeof data === 'object' && data !== null ? data : {};
        this.emit(type, processedData);
      }
    }
    
    export class SpawnMasterPipe extends PipeHandler {
      bindProcess(_proc) {
      }
      post(type, data, proc) {
        proc.send({ type, data });
      }
      handleIPCMessage(message) {
        this.routeMessage(message);
      }
    }
    
    export class SpawnPipe extends PipeHandler {
      constructor() {
        super();
        process.on("message", (message) => this.routeMessage(message));
      }
      post(type, data) {
        process.send?.({ type, data });
      }
    }

    parent-test.js

    import { spawn } from "bun";
    import { SpawnMasterPipe } from "./pipe-handler.js";
    import { logger } from "./logger.js";
    
    class ParentRunner extends SpawnMasterPipe {
      constructor() {
        super();
        this.log = logger(true, "debug");
    
        this.child = spawn({
          cmd: ['bun', 'child-test.js'],
          ipc: (msg) => this.handleIPCMessage(msg),
          stdio: ['ignore', 'inherit', 'ignore'],
        });
    
        this.on("meta", (data) => {
          this.onMeta(data);
        });
        this.on("gc", (data) => {
          this.onGc(data);
        });
    
        this.drain(this.child.stdout);
        this.drain(this.child.stderr);
    
        this.child.exited.then((code) => {
          this.onExit(code);
        });
      }
    
      onMeta(data) {
        this.log.info(`[PARENT] meta: ${JSON.stringify(data)}`);
      }
    
      onGc(data) {
        this.log.info(`[PARENT] GC run: ${JSON.stringify(data)}`);
      }
    
      async drain(stream) {
        if (!stream || typeof stream === 'number') return;
        for await (const _ of stream) {}
      }
    
      onExit(code) {
        this.log.info("Child exited with code", code);
      }
    }
    
    new ParentRunner();

    child-test.js

    import { SpawnPipe } from "./pipe-handler.js";
    import { logger } from "./logger.js";
    
    class Boot extends SpawnPipe {
      static GC_HEAP_THRESHOLD_MB = 60;
      static GC_RSS_THRESHOLD_MB = 250;
    
      heapJunk = [];
    
      constructor() {
        super();
        this.gc = 0;
        this.lastCpuUsage = process.cpuUsage();
        this.lastCpuTime = Date.now();
        this.usage = {
          heapUsed: '0.0',
          rss: '0.0',
          external: '0.0',
          totalHeap: '0.0',
          gcCount: 0,
          cpuPercent: 0,
          timestamp: 0,
        };
    
        this.clientId = process.env.CLIENT_ID || 'demo-client';
        this.version = process.env.CLIENT_VERSION || 'test-1.0';
    
        this.log = logger(true, "debug");
    
        this.metaHandler = () => {
          const payload = {
            topic: `${this.clientId}/meta`,
            clientId: this.clientId,
            version: this.version,
            uptime: Math.floor(process.uptime()),
            usage: { ...this.usage },
          };
          this.post('meta', payload);
        };
    
        // Static allocation, not growing
        this.growHeap();
    
        setInterval(() => this.updateResourceStats(), 500);
        this.postMetaInterval();
    
        if (typeof global.gc === 'function') {
          setInterval(() => this.runGcIfNeeded(), 3 * 60 * 1000);
        }
    
        this.updateResourceStats();
        this.metaHandler();
        this.log.info('[BUN] Test Boot class loaded');
      }
    
      growHeap() {
        for (let i = 0; i < 10; ++i) { // 10 * 5MB = 50MB
          const buf = Buffer.alloc(1024 * 1024 * 5);
          this.heapJunk.push(buf);
        }
        this.log.info(`[CHILD] Allocated static ${this.heapJunk.length * 5}MB total`);
      }
    
      updateResourceStats() {
        const mem = process.memoryUsage();
        const cpu = process.cpuUsage();
        this.usage = {
          heapUsed: (mem.heapUsed / 1024 / 1024).toFixed(1),
          rss: (mem.rss / 1024 / 1024).toFixed(1),
          external: (mem.external / 1024 / 1024).toFixed(1),
          totalHeap: (mem.heapTotal / 1024 / 1024).toFixed(1),
          gcCount: this.gc,
          cpuPercent: this.computeCpuPercent(cpu),
          timestamp: Date.now(),
        };
        this.log.info(`[CHILD] meta: heapUsed=${this.usage.heapUsed}MB | heapTotal=${this.usage.totalHeap}MB | rss=${this.usage.rss}MB | external=${this.usage.external}MB | gcCount=${this.usage.gcCount} | cpu=${this.usage.cpuPercent}%`);
      }
    
      computeCpuPercent(current) {
        const now = Date.now();
        const elapsedMs = now - this.lastCpuTime;
        const userDiff = current.user - this.lastCpuUsage.user;
        const systemDiff = current.system - this.lastCpuUsage.system;
        const totalDiffMicros = userDiff + systemDiff;
        const percent = (totalDiffMicros / 1000) / elapsedMs * 100;
        this.lastCpuUsage = current;
        this.lastCpuTime = now;
        return Math.round(percent);
      }
    
      runGcIfNeeded() {
        const heapUsedMB = Number(this.usage.heapUsed) || 0;
        const rssMB = Number(this.usage.rss) || 0;
        if (heapUsedMB > Boot.GC_HEAP_THRESHOLD_MB || rssMB > Boot.GC_RSS_THRESHOLD_MB) {
          global.gc?.();
          this.gc++;
          this.updateResourceStats();
          this.post('gc', { gcCount: this.gc, ...this.usage });
        }
      }
    
      postMetaInterval() {
        setInterval(this.metaHandler, 500).unref();
      }
    }
    
    new Boot();

    Results:

    • With Pino: Adding Pino increases the baseline RSS by about 20MB (I agree, it’s pretty heavy for logging!). I’m planning to remove it from my codebase for production, or at least swap to raw logs for now.
    • Without Pino: I still see a small, slow memory increase over hours, but it’s much smaller (a few MB over many hours), so it’s not a major leak anymore.
    • In this setup, the heap allocation is static (no growth after startup), so any RSS growth is probably just from Bun’s event loop, IPC, or slow GC/allocator behavior.

    If you want to reproduce, just drop these files in a folder and run bun parent-test.js with:

    bun --expose-gc parent-test.js

    For my prod case, this setup is “close enough”—just imagine the heap is my real application objects.

    ⸻

    Summary:

    • Pino adds a big memory footprint, but even without it, a little slow RSS rise still exists.
    • For most users this is probably fine, but for me (long-running daemon), even slow growth can become a problem over time.
    • I’m okay with this level of growth for now, just wanted to report back with the most real-world code I could make.
    • Thanks for the help, and happy to try or test anything else you want to check!

  4. Jarred-Sumner commented on Aug 2, 2025

    @Jarred-Sumner
    Collaborator

    ah in this case it's likely an issue with either IPC or maybe pino is quietly buffering something. setInterval is unrelated.

    i suggest:

    • once memory usage gets large enough, use the v8 module to dump a heap snapshot and see if there are a large count of any specific type
    • console.log heapStats() from bun:jsc once RSS gets high enough

    also you could try setting serialization to "json"

  5. acoBOYZ commented on Aug 3, 2025

    @acoBOYZ
    Author

    • Pino Removed:
    I fully removed Pino and now only use a custom lightweight logger. This drops baseline RSS, but I still see a slow RSS increase over time.
    • IPC Serialization Set to JSON:
    Before moving on to more advanced tests, I switched Bun’s spawn IPC to serialization: "json" (not the default). This didn’t fix the RSS issue.
    • Only RSS Increases:
    Throughout multi-hour runs, only the rss value climbs (example: heapUsed=44.6MB | heapTotal=22.1MB | rss=218.4MB | external=25.7MB).
    heapUsed, heapTotal, and external stay totally flat—no heap growth, no buffer leak.
    • GC Has No Effect:
    Manual GC (Bun.gc(true)) makes no difference to RSS behavior.

    ⸻

    Major OS Difference:
    • Linux (Ubuntu 24.04, x86_64):
    RSS climbs steadily and just keeps going up over time. It ends up about 3x higher than macOS for the same workload. After rising, it never goes back down, even after forced GC or idle time.
    • macOS (Sequoia 15.5, arm64):
    RSS starts lower, rises slower, and the OS sometimes reclaims RSS and brings memory back down automatically. This never happens on Linux.

    ⸻

    Conclusion / What I’m Seeing:
    • The issue is not in JS/userland. Logging is minimal, serialization is JSON, and the heap stays flat.
    • This seems like native memory (mimalloc, IPC/event loop, or something internal to Bun).
    • The difference between Linux and macOS is huge for long-lived processes.
    • This is a real problem for apps that need to run for days—Linux boxes use a lot more RAM over time.

    ⸻

    Happy to provide (just show me the point):
    • Heap snapshots (generateHeapSnapshot) at any point
    • Native heap stats (MIMALLOC_SHOW_STATS=1)
    • Logs or custom test cases

    ⸻

    Questions for Bun Team:
    • Is this slow RSS increase on Linux expected, or is it something you’re already tracking?
    • Any way to make Bun or mimalloc return memory to the OS more aggressively, like macOS seems to do automatically?

    ⸻

    Thanks for looking into this! Let me know if you want any other info or specific experiments.

    new logger.js

    const COLORS = {
      debug: '\x1b[36m',    // cyan
      info: '\x1b[32m',     // green
      warn: '\x1b[33m',     // yellow
      error: '\x1b[31m',    // red
      reset: '\x1b[0m',
    };
    
    const LEVELS = ['debug', 'info', 'warn', 'error'];
    
    export const logger = (debug = true, level = 'debug') => {
      const minLevelIdx = LEVELS.indexOf(level);
      function format(level, ...args) {
        if (!debug) return;
        const now = new Date();
        const time = now.toLocaleString('en-GB', { hour12: false }).replace(',', '');
        const color = COLORS[level] || '';
        const reset = COLORS.reset;
        if (LEVELS.indexOf(level) < minLevelIdx) return;
        console.log(`${color}[${level.toUpperCase()}][${time}]${reset}`, ...args);
      }
      return {
        debug: (...args) => format('debug', ...args),
        info: (...args) => format('info', ...args),
        warn: (...args) => format('warn', ...args),
        error: (...args) => format('error', ...args),
      };
    };
  6. Jarred-Sumner commented on Aug 3, 2025

    @Jarred-Sumner
    Collaborator

    what if you try the canary build of bun? bun upgrade --canary. we made some improvements to memory usage in certain scenarios.

  7. acoBOYZ commented on Aug 3, 2025

    @acoBOYZ
    Author

    Production Code Results (Bun 1.2.20 Canary, 1 hour, MIMALLOC_PURGE_DELAY=0):

    Platform heapUsed (MB) heapTotal (MB) external (MB) rss (MB)
    macOS 39–50.2 (flat ~41) ~22.1 (flat) ~30.7 (flat) Starts ~182, periodically drops and stays approx. flat at ~78, oscillates up/down (~12MB)
    Linux ~50.2 (flat ~41) ~22.1 (flat) ~30.7 (flat) Starts ~182, slowly rises to 204 over 1 hour

    Test Code Results (Bun 1.2.20 Canary, 1 hour, static heap allocation, MIMALLOC_PURGE_DELAY=0):

    Platform heapUsed (MB) heapTotal (MB) external (MB) rss (MB)
    macOS 52.5 (flat) 3 (flat) 50.6 (flat) Starts ~33.7, periodically drops and stays flat at ~16.6, up/down (~1MB)
    Linux 51.4 (flat) 1.9 (flat) 50.5 (flat) Starts ~50, slowly rises to 59 over 1 hour

    Notes:

    • The test code is just for logging and static allocation; it is almost identical in structure and logic to my production code, to make sure this isn’t a test-vs-real difference.
      -Bun 1.2.20 canary with MIMALLOC_PURGE_DELAY=0 has greatly reduced RSS growth, but the slow RSS rise is still only on Linux—macOS processes have RSS that gets reclaimed and returns to a lower, stable baseline.
    • All JS heap and external memory stats are totally flat on both platforms and tests—only RSS is affected.
    • This RSS difference (Linux vs macOS) appears even with minimal, no-leak code.

    Suggestions / What might help:

    • On macOS, RSS is automatically reclaimed by the OS, so memory usage goes down on idle. On Linux (with mimalloc), RSS never goes down, even after idle or manual GC. This suggests Bun on Linux might benefit from calling mimalloc’s mi_collect(true) more aggressively, especially after each garbage collection cycle.
    • Alternatively, it could be useful for Bun to expose a JS API or environment flag to control the aggressiveness of native memory purging, at least for long-lived server processes.
    • Maybe there are allocator or kernel-level tricks on macOS that don’t exist or are not enabled by default on Linux—worth investigating if similar behavior can be matched on Linux.
    • I am happy to test any patches, new options, or settings you want to try for more aggressive memory return on Linux.

    Attaching latest test scripts below for reproducibility.

    logger.js

    const COLORS = {
      debug: '\x1b[36m',
      info: '\x1b[32m',
      warn: '\x1b[33m',
      error: '\x1b[31m',
      silent: '',
      reset: '\x1b[0m',
      binding: '\x1b[35m',
    };
    const LEVELS = ['debug', 'info', 'warn', 'error', 'silent'];
    export const logger = (debug = true, level = 'debug', bindings = null) => {
      const minLevelIdx = LEVELS.indexOf(level);
      function format(logLevel, ...args) {
        if (!debug)
          return;
        if (LEVELS.indexOf(logLevel) < minLevelIdx || level === 'silent')
          return;
        const now = new Date();
        const time = now.toLocaleString('en-GB', { hour12: false }).replace(',', '');
        const color = COLORS[logLevel] || '';
        const bindingColor = COLORS.binding;
        const reset = COLORS.reset;
        let bindingStr = '';
        if (bindings) {
          bindingStr = ` ${bindingColor}[${Object.entries(bindings).map(([k, v]) => `${k}:${v}`).join(' ')}]${reset}`;
        }
        console.log(`${color}[${logLevel.toUpperCase()}] [${time}]${bindingStr}${reset}`, ...args);
      }
      const loggerInstance = {
        level,
        child: (childBindings) => logger(debug, level, bindings ? { ...bindings, ...childBindings } : childBindings),
        trace: (...args) => format('debug', ...args),
        debug: (...args) => format('debug', ...args),
        info: (...args) => format('info', ...args),
        warn: (...args) => format('warn', ...args),
        error: (...args) => format('error', ...args),
      };
      return loggerInstance;
    };

    setup-buffered-logs.js

    import { mkdirSync, renameSync, statSync, existsSync, createWriteStream } from "fs";
    import { join, dirname } from "path";
    function isReadableStream(s) {
      return !!s && typeof s !== "number" && typeof s.getReader === "function";
    }
    async function* iterateReadableStream(stream) {
      if (!isReadableStream(stream))
        return;
      const reader = stream.getReader();
      try {
        while (true) {
          const { value, done } = await reader.read();
          if (done)
            break;
          if (value)
            yield value;
        }
      }
      finally {
        try {
          reader.releaseLock();
        }
        catch { }
      }
    }
    function ensureDir(path) {
      try {
        mkdirSync(path, { recursive: true });
      }
      catch { }
    }
    function fileSize(path) {
      try {
        return statSync(path).size;
      }
      catch {
        return 0;
      }
    }
    function rotateFiles(pathBase, keep) {
      for (let i = keep - 2; i >= 0; i--) {
        const src = `${pathBase}.${i}`;
        const dst = `${pathBase}.${i + 1}`;
        if (existsSync(src)) {
          try {
            renameSync(src, dst);
          }
          catch { }
        }
      }
    }
    function openAppender(pathBase) {
      ensureDir(dirname(`${pathBase}.0`));
      return createWriteStream(`${pathBase}.0`, { flags: "a" });
    }
    export function setupBufferedLogs(proc, opts) {
      const { clientId, mode = "discard", dir = "logs", basename = `${clientId}.err`, maxBytes = 5 * 1024 * 1024, keep = 3, } = opts;
      let closed = false;
      const pathBase = join(dir, basename);
      let stream = null;
      let currentSize = 0;
      if (mode === "file") {
        ensureDir(dir);
        currentSize = fileSize(`${pathBase}.0`);
        stream = openAppender(pathBase);
      }
      async function handleChunk(u8) {
        if (mode === "discard" || !stream)
          return;
        if (currentSize + u8.byteLength > maxBytes) {
          try {
            stream.close();
          }
          catch { }
          rotateFiles(pathBase, keep);
          currentSize = 0;
          stream = openAppender(pathBase);
        }
        currentSize += u8.byteLength;
        stream.write(u8);
      }
      if (proc.stderr && typeof proc.stderr !== "number") {
        (async () => {
          try {
            for await (const chunk of iterateReadableStream(proc.stderr)) {
              if (closed)
                break;
              await handleChunk(chunk);
            }
          }
          catch {
          }
        })();
      }
      if (proc.stdout && typeof proc.stdout !== "number") {
        (async () => {
          try {
            for await (const _ of iterateReadableStream(proc.stdout)) {
              if (closed)
                break;
            }
          }
          catch {
          }
        })();
      }
      const stop = () => {
        closed = true;
        if (stream) {
          try {
            stream.end();
          }
          catch { }
          stream = null;
        }
      };
      proc.exited.finally(stop).catch(() => stop());
      return stop;
    }

    pipe-handler.js

    import os from 'os';
    import { promise as fastqPromise } from "fastq";
    export class PipeHandler {
      constructor() {
        this.callbacks = {};
        this.queue = fastqPromise(this, this.process.bind(this), os.cpus().length);
      }
      on(type, callback) {
        if (!this.callbacks[type]) {
          this.callbacks[type] = [];
        }
        this.callbacks[type].push(callback);
      }
      off(type, callback) {
        const callbacks = this.callbacks[type];
        if (callbacks) {
          const index = callbacks.indexOf(callback);
          if (index !== -1) {
            callbacks.splice(index, 1);
          }
        }
      }
      once(type) {
        return new Promise((resolve) => {
          const handler = (data) => {
            this.off(type, handler);
            resolve(data);
          };
          this.on(type, handler);
        });
      }
      emit(type, data) {
        this.queue.push({ type, data });
      }
      async process({ type, data, }) {
        const callbacks = this.callbacks[type];
        if (callbacks) {
          for (const callback of callbacks) {
            await callback(data);
          }
        }
      }
      routeMessage(message) {
        const { type, data } = message;
        const processedData = typeof data === 'object' && data !== null ? data : {};
        this.emit(type, processedData);
      }
    }
    
    export class SpawnMasterPipe extends PipeHandler {
      bindProcess(_proc) {
      }
      post(type, data, proc) {
        proc.send({ type, data });
      }
      handleIPCMessage(message) {
        this.routeMessage(message);
      }
    }
    
    export class SpawnPipe extends PipeHandler {
      constructor() {
        super();
        process.on("message", (message) => this.routeMessage(message));
      }
      post(type, data) {
        process.send?.({ type, data });
      }
    }

    parent-test.js

    import { spawn } from "bun";
    import { SpawnMasterPipe } from "./pipe-handler.js";
    import { logger } from "./logger.js";
    import { setupBufferedLogs } from './setup-buffered-logs.js';
    
    class ParentRunner extends SpawnMasterPipe {
      constructor() {
        super();
        this.log = logger(true, "debug");
    
        this.child = spawn({
          cmd: ['bun', 'child-test.js'],
          env: {
            ...process.env,
            MIMALLOC_PURGE_DELAY: '0',
            BUN_EXPERIMENTAL_EXPOSE_GC: '1'
          },
          ipc: (msg) => this.handleIPCMessage(msg),
          serialization: 'json',
          stdio: ['ignore', 'inherit', 'ignore'],
        });
    
        this.on("meta", (data) => {
          this.onMeta(data);
        });
        this.on("gc", (data) => {
          this.onGc(data);
        });
        const stopDrain = setupBufferedLogs(this.child, {
          clientId: 'demo-client',
          mode: 'discard',
        });
    
        this.child.exited.then((code) => {
          try { stopDrain?.() } catch { }
          this.onExit(code);
        });
      }
    
      onMeta(data) {
        this.log.info(`[PARENT] child meta: ${JSON.stringify(data.usage, 2, 0)}`);
      }
    
      onGc(data) {
        this.log.info(`[PARENT] GC run: ${JSON.stringify(data, 2, 0)}`);
      }
    
      async drain(stream) {
        if (!stream || typeof stream === 'number') return;
        for await (const _ of stream) { }
      }
    
      onExit(code) {
        this.log.info("Child exited with code", code);
      }
    
      handleChildTerminate() {
        this.post('exit', {}, this.child);
      }
    }
    
    const parent = new ParentRunner();
    
    process.on('SIGTERM', () => {
      console.log(`[PARENT] Heap snapshot written to`);
      parent.handleChildTerminate();
    });

    child-test.js

    import { SpawnPipe } from "./pipe-handler.js";
    import { logger } from "./logger.js";
    import v8 from "node:v8";
    import process from "node:process";
    
    class Boot extends SpawnPipe {
      static GC_HEAP_THRESHOLD_MB = 60;
      static GC_RSS_THRESHOLD_MB = 250;
    
      heapJunk = [];
    
      constructor() {
        super();
        this.gc = 0;
        this.lastCpuUsage = process.cpuUsage();
        this.lastCpuTime = Date.now();
        this.usage = {
          heapUsed: '0.0',
          rss: '0.0',
          external: '0.0',
          totalHeap: '0.0',
          gcCount: 0,
          cpuPercent: 0,
          timestamp: 0,
        };
    
        this.clientId = process.env.CLIENT_ID || 'demo-client';
        this.version = process.env.CLIENT_VERSION || 'test-1.0';
    
        this.log = logger(true, "debug");
    
        this.metaHandler = () => {
          const payload = {
            topic: `${this.clientId}/meta`,
            clientId: this.clientId,
            version: this.version,
            uptime: Math.floor(process.uptime()),
            usage: { ...this.usage },
          };
          this.post('meta', payload);
        };
    
        // Static allocation, not growing
        this.growHeap();
    
        setInterval(() => this.updateResourceStats(), 500);
        this.postMetaInterval();
    
        if (typeof global.gc === 'function') {
          setInterval(() => this.runGcIfNeeded(), 3 * 60 * 1000);
        }
    
        this.updateResourceStats();
        this.metaHandler();
        this.log.info('[BUN] Test Boot class loaded');
    
        this.on('exit', (data) => {
          const path = v8.writeHeapSnapshot();
          this.log.info(`[CHILD] Heap snapshot written to: ${path}`);
          process.exit(0);
        })
      }
    
      growHeap() {
        for (let i = 0; i < 10; ++i) { // 10 * 5MB = 50MB
          const buf = Buffer.alloc(1024 * 1024 * 5);
          this.heapJunk.push(buf);
        }
        this.log.info(`[CHILD] Allocated static ${this.heapJunk.length * 5}MB total`);
      }
    
      updateResourceStats() {
        const mem = process.memoryUsage();
        const cpu = process.cpuUsage();
        this.usage = {
          heapUsed: (mem.heapUsed / 1024 / 1024).toFixed(1),
          rss: (mem.rss / 1024 / 1024).toFixed(1),
          external: (mem.external / 1024 / 1024).toFixed(1),
          totalHeap: (mem.heapTotal / 1024 / 1024).toFixed(1),
          gcCount: this.gc,
          cpuPercent: this.computeCpuPercent(cpu),
          timestamp: Date.now(),
        };
        this.log.info(`[CHILD] meta: heapUsed=${this.usage.heapUsed}MB | heapTotal=${this.usage.totalHeap}MB | rss=${this.usage.rss}MB | external=${this.usage.external}MB | gcCount=${this.usage.gcCount} | cpu=${this.usage.cpuPercent}%`);
      }
    
      computeCpuPercent(current) {
        const now = Date.now();
        const elapsedMs = now - this.lastCpuTime;
        const userDiff = current.user - this.lastCpuUsage.user;
        const systemDiff = current.system - this.lastCpuUsage.system;
        const totalDiffMicros = userDiff + systemDiff;
        const percent = (totalDiffMicros / 1000) / elapsedMs * 100;
        this.lastCpuUsage = current;
        this.lastCpuTime = now;
        return Math.round(percent);
      }
    
      runGcIfNeeded() {
        const heapUsedMB = Number(this.usage.heapUsed) || 0;
        const rssMB = Number(this.usage.rss) || 0;
        if (heapUsedMB > Boot.GC_HEAP_THRESHOLD_MB || rssMB > Boot.GC_RSS_THRESHOLD_MB) {
          global.gc?.();
          this.gc++;
          this.updateResourceStats();
          this.post('gc', { gcCount: this.gc, ...this.usage });
        }
      }
    
      postMetaInterval() {
        setInterval(this.metaHandler, 500).unref();
      }
    }
    
    new Boot();
    
    process.on('SIGINT', () => {
      const path = v8.writeHeapSnapshot();
      console.log(`[CHILD] Heap snapshot written to: ${path}`);
      process.exit(0);
    });
    
    process.on('SIGTERM', () => {
      const path = v8.writeHeapSnapshot();
      console.log(`[CHILD] Heap snapshot written to: ${path}`);
      process.exit(0);
    });
  8. melishev commented on Aug 11, 2025

    @melishev

    @acoBOYZ

    I encountered the same problem in my project. RSS grows even if no load is created and reaches 1 GB, although it started at 70 MB. Perhaps you have found a solution at this point, maybe lowering the bun version?

    @Jarred-Sumner I encountered a problem on bun 1.2.18. Updating to 1.2.20 did not fix the problem.

  9. acoBOYZ commented on Aug 11, 2025

    @acoBOYZ
    Author

    @acoBOYZ

    I encountered the same problem in my project. RSS grows even if no load is created and reaches 1 GB, although it started at 70 MB. Perhaps you have found a solution at this point, maybe lowering the bun version?

    @Jarred-Sumner I encountered a problem on bun 1.2.18. Updating to 1.2.20 did not fix the problem.

    Workaround (not fix):
    I changed my system. I still use bun.spawn to start child process, but child is my own Go program. The Go program does my spawn logic again, and talks to my Bun “spawn master” with gRPC (not Bun IPC). With this, memory is lower, CPU is lower, and RSS is stable for long time. RSS is same on Linux and macOS for same process.

    Future plan (maybe):
    Later I may drop bun.spawn but keep bun service and run the workers inside one Go service using goroutines. For this I need to make strong isolation so one worker crash does not kill others. I will test both ways and pick what is best for my use case:
    • isolation: per-worker context/cancel, panic recover, limits (CPU/mem) if possible, separate logs/metrics
    • comms: keep gRPC API to Bun parent (stable contract)
    • tests: run 24–48h soak tests on Linux + macOS, compare RSS/CPU, latency, and throughput
    • choose the one with lower resources and better performance for my workload

    Env: Ubuntu 24.04 x86_64 (kernel X.Y), Bun 1.2.20-canary, MIMALLOC_PURGE_DELAY=0. macOS Sequoia 15.5 arm64.

  10. sangemaru commented on Dec 4, 2025

    @sangemaru

    For my part, if I have my orchestrator agent spawn a few parallel subagents, I can get that claude process (which relies on Bun) to baloon to 50GB of memory use real fast.

  11. gastonmorixe commented on Jan 20, 2026

    @gastonmorixe

    it's is happening here as well to me. Will add more information after I gather more data.

    $ bun --version
    1.3.6
    Image
  12. jeremiahrhall commented on May 7, 2026

    @jeremiahrhall

    Hi folks, Is this being worked on?

    The mentioned Claude Code bug was closed without explanation. #27514 was closed as a duplicate of this by a bot but that's not a "spawned child process". I can reproduce RSS climbing despite GCs happening with one script, no child process. See repro here: https://github.com/jeremiahrhall/repro-bun-rss

    For now, I cannot use Bun for production workloads due to this issue. We have a lot of excitement and things we love about Bun in my org, but with a memory leak like this that can't be fixed without patching Bun itself, going back to Node is the responsible option. We can check back in when this is sorted. Thankfully moving between the two isn't a herculean effort.

  13. robobun commented on May 12, 2026

    @robobun
    Collaborator

    Root-caused to Bun.gc(true) never scavenging the native allocators after the JS GC runs finalizers:

    • ArrayBuffer contents (Gigacage) and WTF::StringImpl bodies land in libpas page caches and only decommit when the background pas_scavenger thread gets around to it
    • Bun-owned bytes land on mimalloc thread heaps and only purge after the segment delay

    Under sustained churn neither ever gets ahead, so RSS ratchets while the JS heap stays flat — exactly the "heapUsed flat, RSS climbing" pattern reported here and in #27514.

    Reproduced with @jeremiahrhall's harness: post-Bun.gc(true) RSS released goes from 0% → ~95% with the fix.

    PR: #30590

  14. added a commit that references this issue on May 13, 2026
  15. Mnigos commented on Jul 12, 2026

    @Mnigos

    @Jarred-Sumner We’re seeing the same native-allocator/RSS signature in a real, non-IPC ZIP/JSON workload. This appears to be additional evidence for the root cause identified in #30590, not a new diagnosis.

    #30590 found that Bun.gc(true) completed JavaScript GC but did not synchronously scavenge libpas or force mimalloc collection. That PR was closed because Bun’s Rust rewrite removed or reorganized the files it modified.

    Has the equivalent allocator-scavenging fix been implemented in the current codebase? I could not identify a merged successor from the issue or PR history.

    Production measurements

    Bun 1.3.3 on Linux:

    Stage RSS Heap External ArrayBuffers
    Before workload 483 MB 85 MB 33 MB 2 MB
    During workload 623 MB 137 MB 83 MB 51 MB
    After workload and Bun.gc(true) 626 MB 87 MB 32 MB 2 MB
    Later ~727 MB ~101 MB ~36 MB ~3 MB

    The workload downloads a ZIP into an ArrayBuffer, inflates JSON entries, parses them, validates the complete history, and inserts it.

    Live JavaScript, external, and ArrayBuffer memory returns close to baseline, but RSS does not.

    Local reproduction

    A 300,000-record archive reproduced the same behavior:

    • 15.97 MB compressed
    • 162.7 MB uncompressed
    • RSS remained around 1.18 GB
    • External memory returned to approximately 17 MB
    • ArrayBuffer memory returned to approximately 16 MB

    Moving each high-churn phase into a disposable process immediately returns the memory to the OS. After nine child executions, the long-lived parent grew by only approximately 3.8 MB.

    This workaround strongly suggests allocator-retained pages rather than reachable JavaScript objects.

    We have not yet repeated these measurements on Bun 1.3.14 or current canary. I can extract the workload into a standalone Linux reproduction that records:

    • process.memoryUsage()
    • bun:jsc heapStats()
    • heap snapshots before and after GC
    • stable versus current-canary results

    Is a current-tree port of #30590 the intended next step? Is there a branch or canary build you would like us to test?

  16. renehonig commented on Sep 29, 2026

    @renehonig

    Additional data point for the native-allocator RSS retention pattern on a long-lived Bun-compiled server binary (embedded Bun 1.4.2) on Linux x86_64 — the committed-but-not-returned signature here matches the libpas/mimalloc scavenging root-cause from #30590:

    Workload: an opencode serve --service-style server (Node/TypeScript server, JSC only, no workers, no native addons of note), 246 GiB host, no swap, no cgroup limits. Workload is agent-turn JSON traffic; when lanes are quiet the process sits at 250–900 MB for days.

    Ratchet: during active-turn windows, RSS jumps 981 MB → 25.6 GB within a single 5-minute sample (and 18.4 → 31.9 GB in another), then plateaus byte-stably for hours. VmHWM (30.8 GB) > RSS (26 GB) — pages freed but not returned. One 8 GiB mapping holds a byte-identical 5.2 GB RSS across five 1-minute-apart readings. Partial decommit happens once (23.1 → 12.3 → 11.3 → 10.1 GB over ~2 days) and then stalls permanently.

    Not userland: a SIGUSR1-triggered heap snapshot while the process sat at 23.1 GB RSS shows the live JS heap is 172.2 MB (1.62 M nodes, 0 detached, 23.9 MB of strings; profile is routine module footprint). smaps_rollup: 99.9% private-dirty anon; 21.7 GB of RSS sits in the JSC-allocator address band (~125× the live heap it hosts), with named JSJITCode (10 MB RSS) and JSStructureHeap (~0 RSS) negligible. A control instance of the same binary on the same host with little traffic stays at 344 MB.

    So the allocator commits tens of GB to service transient JSON churn (parse + re-serialize of a few hundred MB of text, plausibly ×10–50 transiently) and never gives the pages back at bucket scale. Question for the team: at this scale (8 GiB buckets), is the "partial page clearing" path defeated by fragmentation, or is decommit simply not reached for these regions? I can run candidate builds — the repro is "server process with bursty large-JSON request handling; watch RSS vs live heap."

    Happy to provide the RSS time series and smaps readings; the raw heapsnapshot contains in-process strings so it will not be shared — the aggregate sizes above are complete.

    Application-side report with full anonymized evidence (RSS series, smaps rollup, snapshot aggregates): anomalyco/opencode#52041

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

    bugSomething isn't workingperformanceAn issue with performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions