Repository navigation
Server polyfills are not user-controllable: no way to opt out of @angular/platform-server/init or add server-only polyfills #34515
Description
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(orzone.jsonplatform: 'neutral') is added when the browserpolyfillslist containszone.js,@angular/localize/initis added when the browser list contains it,@angular/platform-server/initis 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/buildto strip theplatform-server/initimport frompolyfills.server.mjs(modules/ssr-benchmarks/README.mdin angular/angular), and themain.server.tsthere 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 ontoglobalThisregardless. - Apps that want to keep
globalThisclean on the server (noHTMLElement,Node,Event, … leaking into third-party code that feature-detects the environment withtypeof 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
serverlist is the server polyfills list, the same way the browser list already is: the builder adds nothing on top. To drop theinitglobal patch, omit it. To keep today's output, list it. Theng new/ssrschematics can emit the explicit form so new projects see what they get. isZonelessAppis evaluated per list (browser list for the browser bundle, server list for the server bundle), sozone.jsvszone.js/nodeand the async/await support flags fall out naturally.getEsBuildCommonPolyfillsOptionsand thepolyfills.serverbundle are unchanged; only the input list is. Normalization follows the existingnormalizeOptimization/normalizeSourceMapspattern (a smallnormalizePolyfillsinoptions.ts, folding in the string-to-array shim thatbrowser-esbuildstill relies on).- Consumers that read
options.polyfillsand would switch to.browser/.server:application-code-bundle.ts(browser bundle, server bundle,isZonelessAppchecks),execute-build.ts(top-level-await hint),dev-server/vite/index.ts(es2016target), andbrowser-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/nodeor@angular/platform-server/init;tests/options/polyfills_spec.tsonly coversdist/browser/polyfills.js. Either solution should add a server polyfills spec. - The webpack server builder only adds
initwhen@angular/platform-serveris installed (isPackageInstalledinwebpack/configs/common.ts); the esbuild path has no equivalent guard. - Repro and measurements: https://github.com/ChristopherPHolder/ng-double-domino
Activity
alan-agius4 commentedon Oct 24, 2026
Thanks for the detailed writeup and investigation!
A few points I would like to clarify here:
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/localizewhen 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. Thepolyfillsoption inangular.jsonis meant to define application level capabilities rather than low level bundle entry lists.ɵENABLE_DOM_EMULATIONis 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.@angular/platform-server/initis necessary for module evaluation time. WhileDominoAdapter.makeCurrent()applies DOM shims during platform initialization,initis needed before that when modules are first evaluated. Any top level code or third party dependency referencing DOM globals (such asHTMLElement,customElements, orEventin@angular/elementsor web component libraries) will fail immediately with aReferenceErrorwhen the server bundle loads ifinitis not present. Deferring this to platform initialization would also not keepglobalThisclean, as Domino still mutatesglobalThison the very first render request anyway.The disk and heap overhead cited from Server build bundles two copies of Domino, so DOM globals from
initand theDominoAdapterare 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 frominitand theDominoAdapterare different classes #34514. Because platform server requires Domino forDominoAdapteranyway, omittinginitwould not reduce the server bundle footprint because Domino would simply be bundled into the server chunk instead. Theinitmodule itself is only a few lines of code and adds negligible overhead.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 insrc/main.server.ts.Given the above, we do not plan to introduce user controllable server polyfills, and I will be closing this issue.