Skip to content

Server polyfills are not user-controllable: no way to opt out of @angular/platform-server/init or add server-only polyfills #34515

Closed as not planned

Description

@ChristopherPHolder

Command

build, serve

Description

@angular/build:application derives the server polyfills list entirely on its own and offers no option to change it. In createServerPolyfillBundleOptions (application-code-bundle.ts#L164-L184):

  • zone.js/node (or zone.js on platform: 'neutral') is added when the browser polyfills list contains zone.js,
  • @angular/localize/init is added when the browser list contains it,
  • @angular/platform-server/init is always added.

So the browser polyfills list is fully explicit, and the server polyfills list is fully implicit. Two concrete gaps follow from that:

1. There is no opt-out of the init global patch

@angular/platform-server/init is a side-effectful module whose only job is Object.assign(globalThis, domino.impl) plus globalThis.KeyboardEvent = domino.impl.Event. The framework introduced it as something an app may import before bootstrap for code that touches DOM globals at module-evaluation time (angular/angular#40559). Most apps do not need it: platform-server runs the identical shim itself in DominoAdapter.makeCurrent() → setDomTypes() on every platform init, so for everything that runs after bootstrap the init patch is redundant and is overwritten on the first render anyway.

Since v15 the CLI adds it to every server build with no way to say no. Evidence that people need the opt-out:

  • The framework's own SSR benchmarks patch @angular/build to strip the platform-server/init import from polyfills.server.mjs (modules/ssr-benchmarks/README.md in angular/angular), and the main.server.ts there notes that ɵENABLE_DOM_EMULATION "won't prevent the monkey patching of DOM apis as this is introduced by the CLI".
  • Anyone running the server bundle outside Node with a real DOM (the benchmark case, or ENABLE_DOM_EMULATION: false) gets Domino's classes forced onto globalThis regardless.
  • Apps that want to keep globalThis clean on the server (no HTMLElement, Node, Event, … leaking into third-party code that feature-detects the environment with typeof window/typeof HTMLElement) have no option either.

Measured on a default ng new --ssr app (details in #34514): the init module costs ~235 kB on disk, ~1.1 MB of retained heap for the life of the process and ~7 ms of startup, while the Object.assign itself is negligible (~7 KB, 0.03 ms). Prerendered and SSR output are byte-identical with and without it.

2. There is nowhere to put a server-only polyfill

The ssr.platform option's own description says "Please note that this feature does not provide polyfills for Node.js modules." With platform: 'neutral' (Cloudflare Workers and similar), users are expected to bring polyfills themselves, yet the only polyfills option feeds the browser bundle. Today a server-only polyfill has to be imported manually at the top of main.server.ts, where it runs after zone.js and init and after any shared chunks.

Describe the solution you'd like

Let polyfills take either the current array or a browser/server object, mirroring the existing outputPath shape (string | { base, browser, server, media }):

"polyfills": ["zone.js"]                                 // today, unchanged
"polyfills": {
  "browser": ["zone.js"],
  "server": ["zone.js/node", "@angular/platform-server/init"]
}

Semantics:

  • Array form keeps today's behaviour exactly: browser = the array, server = derived as now (zone.js/node, localize, init). Non-breaking; no migration needed.
  • An explicit server list is the server polyfills list, the same way the browser list already is: the builder adds nothing on top. To drop the init global patch, omit it. To keep today's output, list it. The ng new / ssr schematics can emit the explicit form so new projects see what they get.
  • isZonelessApp is evaluated per list (browser list for the browser bundle, server list for the server bundle), so zone.js vs zone.js/node and the async/await support flags fall out naturally.
  • getEsBuildCommonPolyfillsOptions and the polyfills.server bundle are unchanged; only the input list is. Normalization follows the existing normalizeOptimization / normalizeSourceMaps pattern (a small normalizePolyfills in options.ts, folding in the string-to-array shim that browser-esbuild still relies on).
  • Consumers that read options.polyfills and would switch to .browser / .server: application-code-bundle.ts (browser bundle, server bundle, isZonelessApp checks), execute-build.ts (top-level-await hint), dev-server/vite/index.ts (es2016 target), and browser-esbuild's string handling.

This solves both gaps with one option and no new concept: server polyfills become as explicit as browser polyfills.

I am happy to implement this and open the PR once the shape is agreed; the single-copy fix in #34516 is independent of it and already covers the duplicate.

Describe alternatives you've considered

A boolean under ssr, next to ssr.platform, which already shapes the server polyfill list (zone.js vs zone.js/node), the createRequire banner and the xhr2 external:

"ssr": { "entry": "src/server.ts", "platform": "node", "domShims": false }

One if around line 184. It only addresses gap 1, and the name needs care so it is not confused with the framework's private ɵENABLE_DOM_EMULATION, which controls the adapter and does not affect the global shims.

Leaving it manual: users who want init could import it in main.server.ts as the v12 schematic did. That would be a breaking change for apps that rely on the implicit import, and it still leaves gap 2.

Not an alternative, but related: the same unconditional init import is also why the server build currently ships two copies of Domino (#34514). That has a bundling-level fix that does not need this option; this request is about control, not about the duplicate.

Anything else relevant?

  • No spec or e2e currently asserts anything about polyfills.server.mjs, zone.js/node or @angular/platform-server/init; tests/options/polyfills_spec.ts only covers dist/browser/polyfills.js. Either solution should add a server polyfills spec.
  • The webpack server builder only adds init when @angular/platform-server is installed (isPackageInstalled in webpack/configs/common.ts); the esbuild path has no equivalent guard.
  • Repro and measurements: https://github.com/ChristopherPHolder/ng-double-domino

Activity

  1. added this to the needsTriage milestone on Oct 23, 2026
  2. alan-agius4 commented on Oct 24, 2026

    @alan-agius4
    Collaborator

    Thanks for the detailed writeup and investigation!

    A few points I would like to clarify here:

    1. The premise that browser polyfills are fully explicit while server polyfills are implicit is not entirely accurate. The CLI already shapes the browser polyfills bundle depending on the project configuration. For instance, in JIT mode it injects @angular/compiler, when a source locale is defined it injects the locale data, and it removes @angular/localize when translation inlining is enabled. Similarly on the server side, polyfills are derived declaratively from the same application configuration. If an application is zoneless, Zone.js is omitted. If it is zoned, the appropriate node or neutral entry is used. If localize is configured, localize init is included. The polyfills option in angular.json is meant to define application level capabilities rather than low level bundle entry lists.

    2. ɵENABLE_DOM_EMULATION is a private token used solely for internal framework benchmarks in the angular repository. In those benchmarks, the SSR code was executed inside a headless Chrome browser to measure rendering performance against a real DOM. Running outside a browser with DOM emulation disabled is explicitly rejected at runtime by platform server (RuntimeError: 5704 (DISABLED_DOM_EMULATION_IN_NON_BROWSER)). DOM emulation is required for Angular SSR to function, so providing an option to turn it off in server builds would expose an invalid and unsupported state.

    3. @angular/platform-server/init is necessary for module evaluation time. While DominoAdapter.makeCurrent() applies DOM shims during platform initialization, init is needed before that when modules are first evaluated. Any top level code or third party dependency referencing DOM globals (such as HTMLElement, customElements, or Event in @angular/elements or web component libraries) will fail immediately with a ReferenceError when the server bundle loads if init is not present. Deferring this to platform initialization would also not keep globalThis clean, as Domino still mutates globalThis on the very first render request anyway.

    4. The disk and heap overhead cited from Server build bundles two copies of Domino, so DOM globals from init and the DominoAdapter are different classes #34514 is caused by Domino being bundled twice across independent build contexts. This is a separate bundling issue tracked in Server build bundles two copies of Domino, so DOM globals from init and the DominoAdapter are different classes #34514. Because platform server requires Domino for DominoAdapter anyway, omitting init would not reduce the server bundle footprint because Domino would simply be bundled into the server chunk instead. The init module itself is only a few lines of code and adds negligible overhead.

    5. Server polyfills are already supported through standard imports. Unlike browsers which depend on script tags injected into the HTML document, server applications already have explicit entry points. Runtime shims can be imported directly at the top of src/server.ts, and build time or SSG shims can be imported in src/main.server.ts.

    Given the above, we do not plan to introduce user controllable server polyfills, and I will be closing this issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions