bun
bun copied to clipboard
Memory leak running Next.js standalone bundle
What version of Bun is running?
1.1.1+ca1dbb4eb
What platform is your computer?
Darwin 23.4.0 arm64 arm
What steps can reproduce the bug?
- Create a new Next.js app.
$ bun create next-app --ts --tailwind --app --use-bun --eslint --no-src-dir --import-alias "@/*" test`
- Make modifications to the following files.
next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {output: "standalone"};
export default nextConfig;
app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export const dynamic = "force-dynamic";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
- Build Next.js standalone bundle.
$ bun run build
- Run Next.js standalone bundle.
$ bun run .next/standalone/server.js
- Spam your Next.js app with web requests.
$ go install -mod=mod github.com/codesenberg/bombardier
$ bombardier -c 200 -d 1000h http://localhost:3000
- Notice memory usage go up indefinitely.
What is the expected behavior?
No memory leak. Next.js on Node generally consumes roughly 400MB of memory. Even with assumptions on increased memory usage, I would assume Next.js on Bun should consume no more than 1GB of memory.
What do you see instead?
Additional information
Happy to help with debugging/fixing this memory leak - would like to know what tools maintainers are using for debugging these memory leaks.
Getting the same with latest bun version. Was not facing in bun 1.0.35
Getting the same with latest bun version. Was not facing in bun 1.0.35
Tested it out with bun 1.0.35+940448d6b and got the same issue.
I unfortunately have some deadlines to attend to so I can't look further into this for a bit, but it looks like the main issue has to do with a blowup of objects/Function's/AbortSignal's being kept in memory and not being garbage collected.
Here are the initial object counts dumped via. require("bun:jsc").heapStats()
:
{
"objects": {
"AbortController": 530,
"AbortSignal": 7335,
"Arguments": 15045,
"Array Iterator": 2,
"Array": 234404,
"ArrayBuffer": 7709,
"ArrayIterator": 624,
"AsyncContextFrame": 2192,
"AsyncFromSyncIterator": 2,
"AsyncFunction": 38181,
"AsyncGenerator": 202,
"AsyncGeneratorFunction": 4,
"AsyncIterator": 2,
"BigInt": 5,
"BigInt64ArrayPrototype": 1,
"BigUint64ArrayPrototype": 1,
"Blob": 1,
"Boolean": 42,
"BroadcastChannel": 2,
"Buffer": 1,
"BufferList": 14268,
"Bun": 1,
"ByteLengthQueuingStrategy": 2,
"CallSite": 186,
"Callee": 4,
"CountQueuingStrategy": 2,
"Crypto": 2,
"CryptoHasher": 1,
"CryptoKey": 2,
"CustomGetterSetter": 14371,
"DOMAttributeGetterSetter": 151,
"Dirent": 1,
"Error": 17218,
"EvalExecutable": 8,
"Event": 2,
"EventEmitter": 14270,
"EventTarget": 2,
"Exception": 376,
"File": 1,
"FinalizationRegistry": 3,
"Float32ArrayPrototype": 1,
"Float64ArrayPrototype": 1,
"FormData": 2,
"Function": 1403538,
"FunctionCodeBlock": 2636,
"FunctionExecutable": 13632,
"FunctionRareData": 66861,
"Generator": 7243,
"GeneratorFunction": 37,
"GetterSetter": 31332,
"GlobalObject": 2,
"HTTPServer": 2,
"HashMapBucket": 59188,
"Headers": 7263,
"Immutable Butterfly": 2628,
"ImportMeta": 2,
"Int16ArrayPrototype": 1,
"Int32ArrayPrototype": 1,
"Int8ArrayPrototype": 1,
"InternalFieldTuple": 2,
"InternalModuleRegistry": 1,
"InternalPromise": 17,
"InternalPromisePrototype": 2,
"Intl": 2,
"Intl.DurationFormat": 2,
"Intl.ListFormat": 2,
"Iterator": 2,
"JSAsyncGeneratorFunction": 5,
"JSGlobalLexicalEnvironment": 2,
"JSGlobalProxy": 3,
"JSLexicalEnvironment": 438858,
"JSModuleEnvironment": 9,
"JSON": 1,
"JSPropertyNameEnumerator": 81,
"JSSourceCode": 8,
"Map Iterator": 2,
"Map": 22069,
"Math": 1,
"MessageChannel": 2,
"MessagePort": 2,
"Module": 302,
"ModuleLoader": 2,
"ModuleNamespaceObject": 7,
"ModuleProgramExecutable": 4,
"ModulePrototype": 1,
"ModuleRecord": 8,
"NativeExecutable": 883,
"NextTickQueue": 1,
"NodeJSFS": 2,
"Number": 2,
"Object": 665960,
"Performance": 3,
"PerformanceEntry": 2,
"PerformanceMark": 2,
"PerformanceMeasure": 2,
"PerformanceObserver": 2,
"PerformanceObserverEntryList": 2,
"Process": 1,
"ProcessBindingConstants": 1,
"ProgramExecutable": 243,
"Promise": 193255,
"PropertyTable": 1092,
"Prototype": 1,
"Proxy": 1,
"ProxyObject": 15,
"ReadableByteStreamController": 1041,
"ReadableHTTPResponseSinkController": 6934,
"ReadableState": 14268,
"ReadableStream": 10568,
"ReadableStreamBYOBReader": 2,
"ReadableStreamBYOBRequest": 2,
"ReadableStreamDefaultController": 9329,
"ReadableStreamDefaultReader": 9539,
"Reflect": 1,
"RegExp String Iterator": 2,
"RegExp": 1859,
"Request": 7134,
"ResolveMessage": 1,
"Response": 6934,
"ScopedArgumentsTable": 12,
"Script": 2,
"Set Iterator": 42,
"Set": 16363,
"ShadowRealm": 2,
"SparseArrayValueMap": 77,
"Stats": 1,
"String Iterator": 2,
"String": 2,
"StringDecoder": 2,
"Structure": 16853,
"StructureChain": 591,
"StructureRareData": 2398,
"SubtleCrypto": 3,
"Symbol": 2,
"SymbolTable": 3003,
"TextDecoder": 1017,
"TextEncoder": 631,
"Timeout": 656,
"TransformStream": 8777,
"TransformStreamDefaultController": 8777,
"URL": 2,
"URLSearchParams": 3,
"Uint16ArrayPrototype": 1,
"Uint32ArrayPrototype": 1,
"Uint8Array": 28546,
"Uint8ArrayPrototype": 1,
"Uint8ClampedArrayPrototype": 1,
"UnlinkedEvalCodeBlock": 8,
"UnlinkedFunctionCodeBlock": 2363,
"UnlinkedFunctionExecutable": 14175,
"UnlinkedModuleProgramCodeBlock": 4,
"UnlinkedProgramCodeBlock": 263,
"WeakMap": 414,
"WeakRef": 16,
"WeakSet": 23,
"WebAssembly": 2,
"WebAssembly.Instance": 2,
"WebAssembly.Memory": 2,
"WebAssembly.Module": 2,
"WebAssembly.Table": 2,
"WebAssemblyFunction": 42,
"WebAssemblyModuleRecord": 1,
"WebAssemblyWrapperFunction": 7,
"WebSocket": 2,
"Worker": 2,
"WritableStream": 8839,
"WritableStreamDefaultController": 21688,
"WritableStreamDefaultWriter": 8732,
"console": 1,
"require": 1,
"resolve": 1,
"string": 251547,
"symbol": 354
},
"protected": {
"AsyncContextFrame": 487,
"Function": 7030,
"GlobalObject": 1,
"HTTPServer": 1,
"RegExp": 32,
"UnlinkedFunctionExecutable": 5,
"UnlinkedModuleProgramCodeBlock": 4,
"UnlinkedProgramCodeBlock": 263
}
}
After 3 minutes of spamming requests via. 200 concurrent connections using bombardier
:
{
"objects": {
"AbortController": 139,
"AbortSignal": 47840,
"Arguments": 95941,
"Array Iterator": 2,
"Array": 879894,
"ArrayBuffer": 47781,
"ArrayIterator": 61,
"AsyncContextFrame": 260,
"AsyncFromSyncIterator": 2,
"AsyncFunction": 239558,
"AsyncGenerator": 63,
"AsyncGeneratorFunction": 4,
"AsyncIterator": 2,
"BigInt": 5,
"BigInt64ArrayPrototype": 1,
"BigUint64ArrayPrototype": 1,
"Blob": 1,
"Boolean": 2,
"BroadcastChannel": 2,
"Buffer": 1,
"BufferList": 95602,
"Bun": 1,
"ByteLengthQueuingStrategy": 2,
"CallSite": 186,
"Callee": 4,
"CountQueuingStrategy": 2,
"Crypto": 2,
"CryptoHasher": 1,
"CryptoKey": 2,
"CustomGetterSetter": 95705,
"DOMAttributeGetterSetter": 151,
"Dirent": 1,
"Error": 97984,
"EvalExecutable": 8,
"Event": 2,
"EventEmitter": 95604,
"EventTarget": 2,
"Exception": 61,
"File": 1,
"FinalizationRegistry": 3,
"Float32ArrayPrototype": 1,
"Float64ArrayPrototype": 1,
"FormData": 2,
"Function": 6346591,
"FunctionCodeBlock": 2560,
"FunctionExecutable": 13632,
"FunctionRareData": 290854,
"Generator": 1450,
"GeneratorFunction": 37,
"GetterSetter": 193872,
"GlobalObject": 2,
"HTTPServer": 2,
"HashMapBucket": 198171,
"Headers": 47863,
"Immutable Butterfly": 451,
"ImportMeta": 2,
"Int16ArrayPrototype": 1,
"Int32ArrayPrototype": 1,
"Int8ArrayPrototype": 1,
"InternalFieldTuple": 2,
"InternalModuleRegistry": 1,
"InternalPromise": 17,
"InternalPromisePrototype": 2,
"Intl": 2,
"Intl.DurationFormat": 2,
"Intl.ListFormat": 2,
"Iterator": 2,
"JSAsyncGeneratorFunction": 5,
"JSGlobalLexicalEnvironment": 2,
"JSGlobalProxy": 3,
"JSLexicalEnvironment": 2036409,
"JSModuleEnvironment": 9,
"JSON": 1,
"JSPropertyNameEnumerator": 74,
"JSSourceCode": 8,
"Map Iterator": 2,
"Map": 97652,
"Math": 1,
"MessageChannel": 2,
"MessagePort": 2,
"Module": 302,
"ModuleLoader": 2,
"ModuleNamespaceObject": 7,
"ModuleProgramExecutable": 4,
"ModulePrototype": 1,
"ModuleRecord": 8,
"NativeExecutable": 885,
"NextTickQueue": 1,
"NodeJSFS": 2,
"Number": 2,
"Object": 2866775,
"Performance": 3,
"PerformanceEntry": 2,
"PerformanceMark": 2,
"PerformanceMeasure": 2,
"PerformanceObserver": 2,
"PerformanceObserverEntryList": 2,
"Process": 1,
"ProcessBindingConstants": 1,
"ProgramExecutable": 243,
"Promise": 783943,
"PropertyTable": 999,
"Prototype": 1,
"Proxy": 1,
"ProxyObject": 13,
"ReadableByteStreamController": 277,
"ReadableHTTPResponseSinkController": 47740,
"ReadableState": 95602,
"ReadableStream": 48534,
"ReadableStreamBYOBReader": 2,
"ReadableStreamBYOBRequest": 2,
"ReadableStreamDefaultController": 48221,
"ReadableStreamDefaultReader": 48297,
"Reflect": 1,
"RegExp String Iterator": 2,
"RegExp": 606,
"Request": 47801,
"ResolveMessage": 1,
"Response": 47740,
"ScopedArgumentsTable": 12,
"Script": 2,
"Set Iterator": 2,
"Set": 49816,
"ShadowRealm": 2,
"SparseArrayValueMap": 77,
"Stats": 1,
"String Iterator": 2,
"String": 2,
"StringDecoder": 2,
"Structure": 16720,
"StructureChain": 582,
"StructureRareData": 2289,
"SubtleCrypto": 3,
"Symbol": 2,
"SymbolTable": 2471,
"TextDecoder": 237,
"TextEncoder": 106,
"Timeout": 62,
"TransformStream": 48099,
"TransformStreamDefaultController": 48099,
"URL": 2,
"URLSearchParams": 2,
"Uint16ArrayPrototype": 1,
"Uint32ArrayPrototype": 1,
"Uint8Array": 49592,
"Uint8ArrayPrototype": 1,
"Uint8ClampedArrayPrototype": 1,
"UnlinkedEvalCodeBlock": 8,
"UnlinkedFunctionCodeBlock": 1300,
"UnlinkedFunctionExecutable": 13775,
"UnlinkedModuleProgramCodeBlock": 4,
"UnlinkedProgramCodeBlock": 263,
"WeakMap": 184,
"WeakRef": 16,
"WeakSet": 23,
"WebAssembly": 2,
"WebAssembly.Instance": 2,
"WebAssembly.Memory": 2,
"WebAssembly.Module": 2,
"WebAssembly.Table": 2,
"WebAssemblyFunction": 42,
"WebAssemblyModuleRecord": 1,
"WebAssemblyWrapperFunction": 7,
"WebSocket": 2,
"Worker": 2,
"WritableStream": 48137,
"WritableStreamDefaultController": 96276,
"WritableStreamDefaultWriter": 48099,
"console": 1,
"require": 1,
"resolve": 1,
"string": 1122478,
"symbol": 354
},
"protected": {
"AsyncContextFrame": 61,
"Function": 47764,
"GlobalObject": 1,
"HTTPServer": 1,
"RegExp": 32,
"UnlinkedFunctionExecutable": 5,
"UnlinkedModuleProgramCodeBlock": 4,
"UnlinkedProgramCodeBlock": 263
}
}
Here is the custom Next.js server.js
:
const signalPrototype = Object.getOwnPropertyDescriptor(
AbortController.prototype,
"signal"
);
Object.defineProperty(AbortController.prototype, "signal", {
...signalPrototype,
get() {
console.trace("AbortController.signal getter called");
return signalPrototype.get.call(this);
},
});
const { createServer } = require("http");
const { parse } = require("url");
const next = require("next");
const dev = process.env.NODE_ENV !== "production";
const hostname = "localhost";
const port = 3000;
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
import { heapStats } from "bun:jsc";
export function dumpStats() {
const stats = heapStats();
const { objectTypeCounts, protectedObjectTypeCounts } = stats;
Bun.write(
`${new Date().toISOString()}`,
JSON.stringify(
{
objects: Object.fromEntries(Object.entries(objectTypeCounts).sort()),
protected: Object.fromEntries(
Object.entries(protectedObjectTypeCounts).sort()
),
},
null,
2
)
);
}
setInterval(() => {
dumpStats();
}, 10_000);
let count = 0;
app.prepare().then(() => {
createServer(async (req, res) => {
try {
// Be sure to pass `true` as the second argument to `url.parse`.
// This tells it to parse the query portion of the URL.
const parsedUrl = parse(req.url, true);
const { pathname, query } = parsedUrl;
await handle(req, res, parsedUrl);
} catch (err) {
console.error("Error occurred handling", req.url, err);
res.statusCode = 500;
res.end("internal server error");
} finally {
if (count++ % 100 === 0) {
Bun.gc(true);
}
}
})
.once("error", (err) => {
console.error(err);
process.exit(1);
})
.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`);
});
});
A monkey patch on AbortController.signal is included to dump stack traces where it is used in Next.js' runtime source code in case issues to do with garbage collection of AbortSignal's might be the issue.
To further expand on findings from the comment above, the following places were checked:
- Buffer pooling in
HTTPServerWritable
is not the problem. The capacity and length of buffers are valid, and buffers are properly freed/disposed of. - A suspicion was on a potential memory leak in timers (setInterval/setTimeout), or of an overflow in timer ID's in
src.bun.js.api.BunObject.Timer
. I noticed an unused booleanwarned: boolean = false
- but apart from that, timers were properly set up/cleaned up. - Whether or not timers or AbortSIgnal's are garbage collected properly has not yet been checked yet.
Debugging was done with the Instruments leaks profiler, the CPU profiler, Bun's WebKit debugger, and lldb.
I got some time today to continue investigating this. I extracted the core parts of the Next.js server runtime that only deals with WritableStream and AbortController and reproduced the memory leak.
As a sanity check, the following code does not contain a memory leak:
import { createServer } from "http";
import { Readable } from "stream";
const hostname = "localhost";
const port = 3000;
createServer(async (req, res) => {
try {
const { errored, destroyed } = res;
if (errored || destroyed) return;
res.statusCode = 200;
res.statusMessage = "OK";
// send-payload.js
if (req.method === "HEAD") {
res.end(null);
return;
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(Buffer.from("Hello world"));
controller.close();
},
});
Readable.fromWeb(stream).pipe(res);
res.end();
} catch (err) {
console.error("Error occurred handling", req.url, err);
res.statusCode = 500;
res.end("internal server error");
}
})
.once("error", (err) => {
console.error(err);
process.exit(1);
})
.listen(3000, () => {
console.log(`> Ready on http://${hostname}:${port}`);
});
The following code however, with core parts of the Next.js server runtime, does contain a memory leak:
import { ServerResponse, createServer } from "http";
import { Writable } from "stream";
const hostname = "localhost";
const port = 3000;
// detached-promise.ts
export class DetachedPromise<T = any> {
public readonly resolve: (value: T | PromiseLike<T>) => void;
public readonly reject: (reason: any) => void;
public readonly promise: Promise<T>;
constructor() {
let resolve: (value: T | PromiseLike<T>) => void;
let reject: (reason: any) => void;
// Create the promise and assign the resolvers to the object.
this.promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
// We know that resolvers is defined because the Promise constructor runs
// synchronously.
this.resolve = resolve!;
this.reject = reject!;
}
}
// next-request.ts
export const ResponseAbortedName = "ResponseAborted";
export class ResponseAborted extends Error {
public readonly name = ResponseAbortedName;
}
export function isAbortError(e: any): e is Error & { name: "AbortError" } {
return e?.name === "AbortError" || e?.name === ResponseAbortedName;
}
export function createAbortController(response: Writable): AbortController {
const controller = new AbortController();
// If `finish` fires first, then `res.end()` has been called and the close is
// just us finishing the stream on our side. If `close` fires first, then we
// know the client disconnected before we finished.
response.once("close", () => {
if (response.writableFinished) return;
controller.abort(new ResponseAborted());
});
return controller;
}
// pipe-readable.ts
function createWriterFromResponse(
res: ServerResponse,
waitUntilForEnd?: Promise<unknown>
): WritableStream<Uint8Array> {
let started = false;
// Create a promise that will resolve once the response has drained. See
// https://nodejs.org/api/stream.html#stream_event_drain
let drained = new DetachedPromise<void>();
function onDrain() {
drained.resolve();
}
res.on("drain", onDrain);
// If the finish event fires, it means we shouldn't block and wait for the
// drain event.
res.once("close", () => {
res.off("drain", onDrain);
drained.resolve();
});
// Create a promise that will resolve once the response has finished. See
// https://nodejs.org/api/http.html#event-finish_1
const finished = new DetachedPromise<void>();
res.once("finish", () => {
finished.resolve();
});
// Create a writable stream that will write to the response.
return new WritableStream<Uint8Array>({
write: async (chunk) => {
// You'd think we'd want to use `start` instead of placing this in `write`
// but this ensures that we don't actually flush the headers until we've
// started writing chunks.
if (!started) {
started = true;
res.flushHeaders();
}
try {
const ok = res.write(chunk);
// Added by the `compression` middleware, this is a function that will
// flush the partially-compressed response to the client.
if ("flush" in res && typeof res.flush === "function") {
res.flush();
}
// If the write returns false, it means there's some backpressure, so
// wait until it's streamed before continuing.
if (!ok) {
await drained.promise;
// Reset the drained promise so that we can wait for the next drain event.
drained = new DetachedPromise<void>();
}
} catch (err) {
res.end();
throw new Error("failed to write chunk to response", { cause: err });
}
},
abort: (err) => {
if (res.writableFinished) return;
res.destroy(err);
},
close: async () => {
// if a waitUntil promise was passed, wait for it to resolve before
// ending the response.
if (waitUntilForEnd) {
await waitUntilForEnd;
}
if (res.writableFinished) return;
res.end();
return finished.promise;
},
});
}
export async function pipeToNodeResponse(
readable: ReadableStream<Uint8Array>,
res: ServerResponse,
waitUntilForEnd?: Promise<unknown>
) {
try {
// If the response has already errored, then just return now.
const { errored, destroyed } = res;
if (errored || destroyed) return;
// Create a new AbortController so that we can abort the readable if the
// client disconnects.
const controller = createAbortController(res);
const writer = createWriterFromResponse(res, waitUntilForEnd);
await readable.pipeTo(writer, { signal: controller.signal });
} catch (err: any) {
// If this isn't related to an abort error, re-throw it.
if (isAbortError(err)) return;
throw new Error("failed to pipe response", { cause: err });
}
}
// OUR TEST
createServer(async (req, res) => {
try {
const { errored, destroyed } = res;
if (errored || destroyed) return;
res.statusCode = 200;
res.statusMessage = "OK";
// send-payload.js
if (req.method === "HEAD") {
res.end(null);
return;
}
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(Buffer.from("Hello world"));
controller.close();
},
});
await pipeToNodeResponse(stream, res);
res.end();
} catch (err) {
console.error("Error occurred handling", req.url, err);
res.statusCode = 500;
res.end("internal server error");
}
})
.once("error", (err) => {
console.error(err);
process.exit(1);
})
.listen(3000, () => {
console.log(`> Ready on http://${hostname}:${port}`);
});
bun-debug logs running the memory leak reproduction:
[RequestContext] create (src.bun.js.api.server.NewRequestContext(false,false,src.bun.js.api.server.NewServer(ZigGeneratedClasses.JSHTTPServer,false,false))@200005e0100)
[RequestContext] render
[RequestContext] doRender
[RequestContext] finalize (src.bun.js.api.server.NewRequestContext(false,false,src.bun.js.api.server.NewServer(ZigGeneratedClasses.JSHTTPServer,false,false))@200005e0100)
[RequestContext] finalizeWithoutDeinit (src.bun.js.api.server.NewRequestContext(false,false,src.bun.js.api.server.NewServer(ZigGeneratedClasses.JSHTTPServer,false,false))@200005e0100)
[RequestContext] finalizeWithoutDeinit: has_finalized false
[RequestContext] finalizeWithoutDeinit: response_jsvalue != .zero
[RequestContext] finalizeWithoutDeinit: request_body != null
[Server] deinitIfWeCan
[RequestContext] deferred deinit (src.bun.js.api.server.NewRequestContext(false,false,src.bun.js.api.server.NewServer(ZigGeneratedClasses.JSHTTPServer,false,false))@200005e0100)
[RequestContext] deinit (src.bun.js.api.server.NewRequestContext(false,false,src.bun.js.api.server.NewServer(ZigGeneratedClasses.JSHTTPServer,false,false))@200005e0100)
The memory leak has been isolated to the following line:
await readable.pipeTo(writer, { signal: controller.signal });
Specifically, passing in controller.signal
here causes the memory leak to happen.
Filed PR #10086. pipeTo
no longer leaks memory. It seems like there may potentially be some far more minor memory leaks in the NextJS standalone build, though. Will look into it later after working on getting #10086 merged.