V1 Execution state is never cleared after completion, causing memory leak
Describe the bug
V1InngestExecution never clears its internal state after execution completes. The _start() finally block calls this.state.loop.return() but does not clear this.state.steps, this.state.stepState, this.fnArg, or this.execution. This means each execution instance — along with all step closures, arguments, and deferred promises — remains in memory indefinitely until the process is restarted.
For long-running services with cron-triggered functions, this produces a staircase memory growth pattern (~200–300 MB/hour in our case) that eventually causes OOM.
Root cause
Three retention mechanisms prevent V1InngestExecution from being garbage collected:
1. state.steps Map is never cleared
In createStepTools(), each step is stored in this.state.steps with its full closure arguments:
// v1.ts — createStepTools()
const step = {
rawArgs: fnArgs, // full closure arguments (user code + framework runtime)
fn: opts?.fn ? () => opts.fn?.(this.fnArg, ...fnArgs) : undefined,
promise, // deferred promise
handle: () => { ... }, // closure over resolve, reject, this.state
};
this.state.steps.set(hashedId, step);
This Map is never .clear()'d or nulled after _start() completes. Every step's rawArgs and fn closures retain whatever the user's callback captured.
2. this.execution memoization creates a self-reference cycle
// v1.ts — start()
this.execution = getAsyncLocalStorage().then((als) => {
return als.run({
app: this.options.client,
execution: {
ctx: this.fnArg,
instance: this, // ← strong reference back to the execution instance
},
}, async () => {
return this._start().then(...).finally(...);
});
});
this.execution is set once and never nulled. The ALS context includes instance: this, creating a cycle. Even after the Promise settles, the cycle keeps the instance reachable from itself.
3. createDeferredPromiseWithStack() async generator survives return()
// helpers/promises.ts
const results = (async function* () {
while (true) {
const next = settledPromises.shift();
if (next) yield next;
else await new Promise<void>((resolve) => {
rotateQueue = resolve; // ← captured resolve function
});
}
})();
When _start() finally calls this.state.loop.return() → checkpointResults.return(), the inner generator may be suspended on await new Promise(...). In Bun (JavaScriptCore), calling return() on a suspended async generator does not immediately discard the pending Promise — the generator's activation frame (holding settledPromises, rotateQueue) survives.
Evidence from heap analysis
We took a Bun heap snapshot after forcing GC (Bun.gc(true)) following a cron cycle. The snapshot confirms:
| Metric |
Baseline (fresh process) |
After ~1 hour |
RuntimeImpl objects |
42 |
327 (+285) |
| Strings > 100 KB |
~10 MB |
123.5 MB |
rawArgs edges |
0 |
285 |
execution edges |
0 |
96 |
stepState edges |
0 |
64 |
stepCompletionOrder edges |
0 |
64 |
The retention chain from the heap:
V1InngestExecution
└─ state.steps Map
└─ FoundStep.rawArgs (285 edges)
└─ user callback closures
└─ framework runtime objects (327 RuntimeImpl)
└─ large strings (123.5 MB — tweet data, AI conversations)
To Reproduce
- Create a function with multiple steps where some steps return large data (>100 KB):
const fn = inngest.createFunction(
{ id: "my-cron" },
{ cron: "0 * * * *" },
async ({ step }) => {
const bigData = await step.run("collect-data", async () => {
return "x".repeat(1_000_000); // 1 MB
});
const digest = await step.run("digest", async () => {
return bigData.slice(0, 100);
});
await step.run("finalize", async () => {
return { done: true };
});
}
);
- Let the cron fire repeatedly (or simulate by sending step requests)
- After each complete function run (all steps executed across multiple HTTP requests), take a heap snapshot
- Observe that
state.steps Maps from previous executions are still in memory — the execution instances are never collected
Expected behavior
After an execution completes (all steps run, HTTP response sent), the V1InngestExecution instance and all its internal state should become eligible for garbage collection. Specifically:
this.state.steps should be cleared
this.state.stepState should be cleared
this.fnArg should be nulled
this.execution should be nulled (breaking the self-reference cycle)
Suggested fix
In _start() finally block:
async _start() {
try {
// ... existing code ...
} catch (error) {
return await this.transformOutput({ error });
} finally {
this.state.loop.return();
await this.state.hooks?.beforeResponse?.();
// Release execution state to allow GC
this.state.steps.clear();
this.state.stepState = {};
this.state.metadata?.clear();
this.state.remainingStepsToBeSeen.clear();
this.state.checkpointingStepBuffer.length = 0;
this.fnArg = null;
this.execution = null;
}
}
Code snippets / Logs / Screenshots
Memory graph showing the staircase pattern (each spike correlates with the hourly cron):
Memory
800 MB ┤ ╭─
600 MB ┤ ╭──────────────────────────────────╮ │
400 MB ┤ ╭────╯ ╰────╮ │
200 MB ┤─────╯ ╰──────╯
0 B ┤
└────────────────────────────────────────────────────────────
3:15 PM 3:30 PM 3:45 PM 4:00 PM
The dip at 3:17 PM was a deployment (restart). Memory grows ~200–300 MB per cron cycle, never reclaimed even with forced GC.
System info:
- OS: Linux (Railway, Docker) / macOS (local dev)
- npm package version:
inngest@3.52.4
- Framework: Hono
- Runtime: Bun 1.3.11
- Platform: Railway
Additional context
- We use
Effect-TS with FiberSet.makeRuntimePromise() per step call (via a wrapper library). Each rawArgs closure captures the Effect runtime context, making the per-execution retention significant (~2–3 MB per function run with 14 steps).
- The leak persists after
Bun.gc(true) (forced synchronous full GC), confirming genuine retention — not a GC timing issue.
- The V2 execution engine (
v2.ts) appears to have the same pattern — state.steps is also never cleared.
- This likely affects all runtimes (Node.js, Deno, Bun), though the impact is proportional to what user closures capture. Bun/JSC may exacerbate it due to how async generator
return() interacts with pending Promises.
V1 Execution state is never cleared after completion, causing memory leak
Describe the bug
V1InngestExecutionnever clears its internal state after execution completes. The_start()finally block callsthis.state.loop.return()but does not clearthis.state.steps,this.state.stepState,this.fnArg, orthis.execution. This means each execution instance — along with all step closures, arguments, and deferred promises — remains in memory indefinitely until the process is restarted.For long-running services with cron-triggered functions, this produces a staircase memory growth pattern (~200–300 MB/hour in our case) that eventually causes OOM.
Root cause
Three retention mechanisms prevent
V1InngestExecutionfrom being garbage collected:1.
state.stepsMap is never clearedIn
createStepTools(), each step is stored inthis.state.stepswith its full closure arguments:This Map is never
.clear()'d or nulled after_start()completes. Every step'srawArgsandfnclosures retain whatever the user's callback captured.2.
this.executionmemoization creates a self-reference cyclethis.executionis set once and never nulled. The ALS context includesinstance: this, creating a cycle. Even after the Promise settles, the cycle keeps the instance reachable from itself.3.
createDeferredPromiseWithStack()async generator survivesreturn()When
_start()finally callsthis.state.loop.return()→checkpointResults.return(), the inner generator may be suspended onawait new Promise(...). In Bun (JavaScriptCore), callingreturn()on a suspended async generator does not immediately discard the pending Promise — the generator's activation frame (holdingsettledPromises,rotateQueue) survives.Evidence from heap analysis
We took a Bun heap snapshot after forcing GC (
Bun.gc(true)) following a cron cycle. The snapshot confirms:RuntimeImplobjectsrawArgsedgesexecutionedgesstepStateedgesstepCompletionOrderedgesThe retention chain from the heap:
To Reproduce
state.stepsMaps from previous executions are still in memory — the execution instances are never collectedExpected behavior
After an execution completes (all steps run, HTTP response sent), the
V1InngestExecutioninstance and all its internal state should become eligible for garbage collection. Specifically:this.state.stepsshould be clearedthis.state.stepStateshould be clearedthis.fnArgshould be nulledthis.executionshould be nulled (breaking the self-reference cycle)Suggested fix
In
_start()finally block:Code snippets / Logs / Screenshots
Memory graph showing the staircase pattern (each spike correlates with the hourly cron):
The dip at 3:17 PM was a deployment (restart). Memory grows ~200–300 MB per cron cycle, never reclaimed even with forced GC.
System info:
inngest@3.52.4Additional context
Effect-TSwithFiberSet.makeRuntimePromise()per step call (via a wrapper library). EachrawArgsclosure captures the Effect runtime context, making the per-execution retention significant (~2–3 MB per function run with 14 steps).Bun.gc(true)(forced synchronous full GC), confirming genuine retention — not a GC timing issue.v2.ts) appears to have the same pattern —state.stepsis also never cleared.return()interacts with pending Promises.