Skip to content

bug: SSR memory leak: hydrate bundle retains a mode resolver on every render #31466

Description

@SebastianKohler

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:

npm run repro:workaround

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:

  1. Add modes: [] in @ionic/angular-server now as the targeted workaround demonstrated above.
  2. 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: [].

Activity

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions