Prerequisites
Ionic Framework Version
v9.x, v8.x
Current Behavior
Every Ionic server-side hydrate call retains a Stencil hydrate closure. In a production Angular SSR application, memory grows with every request until the container is out of memory.
Expected Behavior
After a server-side render completes, per-render state should be eligible for garbage collection. Repeated hydrateDocument() calls should reach a stable memory level rather than retaining another closure and increasing live heap after every render.
Steps to Reproduce
The attached minimal reproduction uses @ionic/core/hydrate directly, with no Angular application or client hydration. It uses the same fallback options that @ionic/angular-server selects when the server DOM does not implement attachShadow().
Run:
npm install
npm run repro
The script performs 500 identical hydrateDocument() calls and forces a full V8 garbage collection after every completed call.
Observed with Node 24.20.0 and Ionic 9.0.4:
render 1: heapUsed 6.54 MiB
render 100: heapUsed 33.58 MiB
render 200: heapUsed 59.78 MiB
render 300: heapUsed 85.98 MiB
render 400: heapUsed 112.16 MiB
render 500: heapUsed 138.49 MiB
Code Reproduction URL
https://github.com/SebastianKohler/ionic-ssr-memory-leak-repro
Ionic Info
- Node.js: 24.20.0
- npm: 11.19.0
@ionic/core@9.0.4
@ionic/angular-server@9.0.4
The regression is also present in Ionic 8.6.0 and later. Ionic 8.5.9, whose hydrate bundle embeds Stencil Hydrate Platform 4.20.0, does not reproduce it.
Additional Information
Root cause
This is caused by Stencil's module-scoped modeResolutionChain, introduced by stenciljs/core#5953.
Ionic's generated global runtime registers its ios/md mode resolver during every hydrate render. Stencil clears the shared chain only when the caller supplies a modes array. @ionic/angular-server currently calls hydrateDocument() without modes, so every SSR request appends and retains another resolver closure.
The corresponding Stencil bug is stenciljs/core#6901.
Suggested Ionic workaround
Add modes: [] to the options passed to hydrateDocument() in @ionic/angular-server:
return hydrateDocument(doc, {
modes: [],
...(supportsNativeAttachShadow
? { clientHydrateAnnotations: false }
: {
serializeShadowRoot: 'scoped',
clientHydrateAnnotations: true,
}),
excludeComponents: [/* existing list */],
});
This activates Stencil's existing reset branch before Ionic registers its normal resolver. It does not enable or otherwise change Angular client hydration, and it does not require reverting the attachShadow() fallback.
Control:
This adds only modes: [] and keeps live heap approximately flat:
render 1: heapUsed 6.54 MiB
render 100: heapUsed 7.70 MiB
render 200: heapUsed 7.75 MiB
render 300: heapUsed 7.80 MiB
render 400: heapUsed 7.81 MiB
render 500: heapUsed 7.99 MiB
Options for Ionic
@ionic/core/hydrate is a precompiled bundle containing its own Stencil hydrate runtime, so an application-level override of @stencil/core does not replace the affected code.
Ionic can address the leak in either or both of these ways:
- Add
modes: [] in @ionic/angular-server now as the targeted workaround demonstrated above.
- Once stenciljs/core#6901 is fixed, update the Stencil version used to build
@ionic/core and publish a rebuilt hydrate bundle.
These changes are compatible: Ionic can ship the workaround immediately and later update its embedded Stencil runtime. After updating Stencil, the explicit modes: [] reset can either remain as a defensive measure or be removed if it is redundant with the upstream fix.
Version comparison
The same Node-only reproduction was run for 500 identical hydrateDocument() calls, with a full garbage collection after every call:
| Ionic version |
Configuration |
Heap after render 1 |
Heap after render 500 |
Growth |
| 8.5.9 (last unaffected release, Stencil 4.20.0) |
No workaround |
6.30 MiB |
7.31 MiB |
+1.01 MiB |
| 9.0.4 (Stencil 4.45.0) |
No workaround |
6.54 MiB |
138.49 MiB |
+131.95 MiB |
| 9.0.4 (Stencil 4.45.0) |
modes: [] |
6.54 MiB |
7.99 MiB |
+1.45 MiB |
All three runs use serializeShadowRoot: 'scoped' and clientHydrateAnnotations: true. The third run differs from the second only by adding modes: [].
Prerequisites
Ionic Framework Version
v9.x, v8.x
Current Behavior
Every Ionic server-side hydrate call retains a Stencil hydrate closure. In a production Angular SSR application, memory grows with every request until the container is out of memory.
Expected Behavior
After a server-side render completes, per-render state should be eligible for garbage collection. Repeated
hydrateDocument()calls should reach a stable memory level rather than retaining another closure and increasing live heap after every render.Steps to Reproduce
The attached minimal reproduction uses
@ionic/core/hydratedirectly, with no Angular application or client hydration. It uses the same fallback options that@ionic/angular-serverselects when the server DOM does not implementattachShadow().Run:
The script performs 500 identical
hydrateDocument()calls and forces a full V8 garbage collection after every completed call.Observed with Node 24.20.0 and Ionic 9.0.4:
Code Reproduction URL
https://github.com/SebastianKohler/ionic-ssr-memory-leak-repro
Ionic Info
@ionic/core@9.0.4@ionic/angular-server@9.0.4The regression is also present in Ionic 8.6.0 and later. Ionic 8.5.9, whose hydrate bundle embeds Stencil Hydrate Platform 4.20.0, does not reproduce it.
Additional Information
Root cause
This is caused by Stencil's module-scoped
modeResolutionChain, introduced by stenciljs/core#5953.Ionic's generated global runtime registers its
ios/mdmode resolver during every hydrate render. Stencil clears the shared chain only when the caller supplies amodesarray.@ionic/angular-servercurrently callshydrateDocument()withoutmodes, so every SSR request appends and retains another resolver closure.The corresponding Stencil bug is stenciljs/core#6901.
Suggested Ionic workaround
Add
modes: []to the options passed tohydrateDocument()in@ionic/angular-server:This activates Stencil's existing reset branch before Ionic registers its normal resolver. It does not enable or otherwise change Angular client hydration, and it does not require reverting the
attachShadow()fallback.Control:
This adds only
modes: []and keeps live heap approximately flat:Options for Ionic
@ionic/core/hydrateis a precompiled bundle containing its own Stencil hydrate runtime, so an application-level override of@stencil/coredoes not replace the affected code.Ionic can address the leak in either or both of these ways:
modes: []in@ionic/angular-servernow as the targeted workaround demonstrated above.@ionic/coreand publish a rebuilt hydrate bundle.These changes are compatible: Ionic can ship the workaround immediately and later update its embedded Stencil runtime. After updating Stencil, the explicit
modes: []reset can either remain as a defensive measure or be removed if it is redundant with the upstream fix.Version comparison
The same Node-only reproduction was run for 500 identical
hydrateDocument()calls, with a full garbage collection after every call:modes: []All three runs use
serializeShadowRoot: 'scoped'andclientHydrateAnnotations: true. The third run differs from the second only by addingmodes: [].