Fourteen lines of TypeScript, thirteen nested object literals and one export const, produce an identical diagnostic on both compilers. On JavaScript tsc 6.0.3 the reporter of microsoft/TypeScript#63966 measured 0.41 GB peak RSS and 0.66 seconds to print it. On the Go tsc 7.1.0-dev: 2.48 GB and 3.68 seconds for the same bytes of output. On a 2 GiB build container it never prints at all, because the kernel arrives before the diagnostic does.
TL;DR: TypeScript 7's Go compiler has no default heap ceiling. NODE_OPTIONS=--max-old-space-size is silently ignored because the process allocating memory is a Go binary, so a runaway type keeps allocating until the cgroup OOM killer sends SIGKILL and your job exits 137 with zero compiler output. Split the three causes by asking whether a diagnostic printed, pin --checkers instead of inheriting the default 4, and set GOMEMLIMIT to roughly 90% of the container limit.
TypeScript 7.0 shipped on 8 July 2026 as a native Go port, and the headline was memory going down. Microsoft's announcement cites the VS Code codebase falling from 5.2 GB under TypeScript 6 to 4.2 GB under 7. That average holds up. The tail does not, and CI containers are sized for the tail. If you have been chasing V8 heap deaths, the four causes behind Ineffective mark-compacts in Node 24.19.0 describe the failure mode this one replaces, and it replaces it with something quieter.
Where did the heap ceiling go?
Under tsc 6 and earlier, V8 enforced a default old-space limit. A pathological type blew through it and Node threw a JavaScript error you could catch, grep, and alert on: FATAL ERROR: Ineffective mark-compacts near heap limit. The process died inside a budget it set for itself, and it told you so.
Go works differently. GOMEMLIMIT is unset by default, and the Go runtime does not read cgroup memory limits. microsoft/typescript-go#2125, open since 19 November 2025, states the consequence in the report itself: tsgo lacks the default heap ceiling Node provided and keeps allocating until physical RAM and swap are exhausted, hanging the machine until the kernel intervenes.
The container math inverted at the same time. Go 1.25 made GOMAXPROCS cgroup-aware, so CPU now configures itself from the limits you set. The matching memory proposal, golang/go#75164 ("proposal: runtime: cgroup memory limit aware GOMEMLIMIT default"), has been open since 27 August 2025 and sits in the Proposal milestone. CPU adapts to your cgroup. Memory is your job now, and nothing in the toolchain mentions that during the upgrade.
Did a diagnostic print? The question that splits three failures
"tsc died after we upgraded to 7" covers three separate failures with three separate fixes, and the threads conflate them because the surviving symptom is usually silence. The selecting condition is whether the compiler printed anything before it stopped.
Cause 1 is a real TypeScript error, and memory has nothing to do with it. error TS7056: The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed. means the declaration emitter hit its serialization cap. The fix is an explicit type annotation on the exported binding, or dropping declaration for that project. Both compilers refuse to write the .d.ts either way.
Cause 2 is #63966: the Go compiler allocates heavily in its node-builder and deep-clone paths on the way to giving up, and on a small runner it gets killed before it can tell you about the type. Confirm the kill rather than guessing at it:
dmesg -T | grep -i 'killed process'
kubectl get pod <build-pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'
An OOMKilled last-state or a kernel line naming tsgo settles it. Cause 3 is parallelism, and the re-run with --singleThreaded separates it from cause 2 in one build.
Four checkers means four heaps
TypeScript 7 defaults to --checkers 4, plus --builders for project references, and the release announcement is explicit that more checkers means proportionally more memory. That default does not shrink when the runner does. A repository that type-checked comfortably inside 3 GB on TypeScript 6 can now want four concurrent checker heaps on a runner that was sized for one.
This is the cause I would look at first on any self-hosted pod, because it is the one that produces a failure with no relationship to your code. Nothing changed in the types. The compiler simply decided to do four things at once inside a limit that was written for a single-threaded process, and the parallelism that made the port worth adopting is what pushes it over. Pin the number in CI. Inheriting a default that scales with a machine the runtime cannot see is how you get a build that passes locally on 32 GB and dies in the cluster.
What the flag you already set is doing
NODE_OPTIONS=--max-old-space-size=8192 is now decoration. The Node launcher honors it and holds a few megabytes; the compiler inherits the variable and ignores it, because a V8 old-space limit means nothing to a Go allocator. There is no warning and no error.
It fails in both directions. Teams raising the ceiling get no extra headroom. Teams that deliberately lowered it to make runaway type-checking fail fast and cheap have quietly lost that guardrail, and the first sign will be a hung runner rather than a clean crash. Silent inheritance of a flag that stopped meaning anything is the same trap as the module resolution shift behind Cannot find module build/Release after npm 12: the config is still valid syntax, so nothing tells you it has stopped applying.
While you are auditing config, disableSizeLimit deserves a mention because it is the first thing people find when they search this. It removes TypeScript's source-size allocation guard, which is a limit on how much source the compiler will accept, and it has no effect on heap growth. Turning it on here raises your odds of an OOM.
Reading GOMEMLIMIT honestly
GOMEMLIMIT is the closest thing to a replacement ceiling, and it is worth setting. It is also a soft limit, and the Go GC guide documents exactly what that means: the runtime collects more aggressively as the heap approaches the limit, and if the live heap genuinely needs more memory, Go still asks the OS for it and still gets killed.
The failure mode when you set it too low is worse than the one you were trying to prevent. golang/go#58106, "GOMEMLIMIT prolonged high GC CPU utilization before container OOM," describes it: the process burns CPU in back-to-back collections for a long stretch and then dies anyway. You traded a fast, obvious kill for a slow one that looks like a hung build. Set the value from the cgroup limit at about 90%, so the GC has a pressure signal with room to act, and treat it as a way to make the compiler try harder rather than a fix for a type that genuinely needs 2.48 GB.
The honest counterpoint to all of this: #63966 is labelled "Possible Improvement" and parked in the Backlog milestone. Nobody upstream is going to shrink that 6x for you this quarter, so every lever here is yours to pull.
Your CI alerts stopped matching anything
Every retry rule, flaky-build classifier, and Slack alert that greps job logs for JavaScript heap out of memory now matches nothing. The OOM changed from an application error into a signal. What lands in the log is ##[error]Process completed with exit code 137 on GitHub Actions, or an OOMKilled last-state on a Kubernetes build pod, with no compiler output at all, because the diagnostic was never printed.
Anything keyed on a string from a runtime you no longer run is dead weight, and the same audit is worth running across the ESLint and TypeScript 7 toolchain breaks and any Rust-rewritten CLI in the pnpm 12 upgrade path. Native rewrites move failures from stdout into the exit code, and the alerting rarely follows.
Fix your pipeline before the next upgrade PR
- Measure peak RSS before you tune anything.
/usr/bin/time -v npx tsc -p . 2>&1 | grep 'Maximum resident'gives you peak RSS in KB (/usr/bin/time -lon macOS). Set the container limit from that number plus about 30%, and write it down. Peak RSS is a CI budget line item now. - Delete
NODE_OPTIONS=--max-old-space-sizefrom any job that only runs TypeScript 7. It does nothing, and leaving it in place hides the fact that nothing is capping the compiler. - Pin
--checkersin CI. On a runner under 4 GiB, use--checkers 1or--singleThreadedand give back some of the wall time the Go port just handed you. - Set
GOMEMLIMITto about 90% of the cgroup limit:GOMEMLIMIT=3600MiBinside a 4 GiB container. Expect it to slow a pathological build rather than save it. - Rewrite the alert match to exit code 137 and
OOMKilled. A build that dies with no compiler output is a normal TypeScript 7 failure mode, and it needs to page someone. - When
error TS7056does print, annotate the export. That is the fix, and no memory setting changes it.
FAQ
Why does my TypeScript 7 build exit with code 137 and no compiler output? Exit 137 is SIGKILL from the kernel OOM killer. The Go compiler was allocating past the container limit and was killed before it could print a diagnostic. Confirm with dmesg -T | grep -i 'killed process' or an OOMKilled container last-state.
Does NODE_OPTIONS=--max-old-space-size still work with tsgo? No. It is honored by the Node launcher and ignored by the Go compiler doing the allocation, with no warning either way. Remove it from TypeScript 7 jobs.
What should I set GOMEMLIMIT to in a container? About 90% of the cgroup memory limit, so the GC gets a pressure signal with room to act. Lower values risk the prolonged high-GC-CPU behavior reported in golang/go#58106 before the process dies anyway.
Is error TS7056 a memory problem or a type problem? A type problem. The declaration serializer hit its length cap on an inferred type. Adding an explicit type annotation to the exported binding fixes it on both compilers. The memory story is what it costs TypeScript 7 to reach that message.
Should I set disableSizeLimit to stop tsc running out of memory? No. disableSizeLimit removes TypeScript's source-size allocation guard and does not cap the heap. Enabling it makes an OOM more likely.
Comments
Be the first to comment.