skip to content

When embedding several Angular Elements widgets in a non-Angular site, what drives their bundle and runtime cost, and how do you keep it down?

level: seniorimportance: nice to knowfreq 22%

answer

  1. the framework ships with the widget
  2. one build per widget duplicates it
  3. one application, one injector
  4. zoneless default drops a polyfill
  5. stable URLs versus cache busting

basics

~20 s

Every Angular Elements bundle carries the Angular runtime it needs, so separately built widgets duplicate the framework and each bootstraps its own application. Build them together, register all from one createApplication() injector, stay zoneless, and import only what the widgets use.

solid answer

~40 s

A custom element built with `@angular/elements` is not a lightweight wrapper: its bundle includes `@angular/core`, `@angular/platform-browser` and everything the component imports, such as `HttpClient`. If each widget is a separate Angular build, the page downloads and parses the framework once per widget, and each `createApplication()` creates its own application and root injector, so `providedIn: 'root'` services are not shared between widgets. The cheaper layout is one bundle that defines every tag from a single application injector. Since v21 zoneless change detection is the default, so the bundle needs no `zone.js` polyfill that would patch the host page's globals. Then trim imports, and decide how the CMS references the file: the `ng new` production configuration hashes file names (`outputHashing: 'all'`), which the embed has to account for.

code

ts · 18 lines
ts
import { createApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { createCustomElement } from '@angular/elements';
import { FeedbackWidget } from './feedback-widget';
import { Rating } from './rating';

const widgets = [
  ['kj-feedback', FeedbackWidget],
  ['kj-rating', Rating],
] as const;

createApplication({ providers: [provideHttpClient()] })
  .then((appRef) => {
    for (const [tag, component] of widgets) {
      customElements.define(tag, createCustomElement(component, { injector: appRef.injector }));
    }
  })
  .catch((err) => console.error(err));

go deeper

for a junior

Know that an Angular custom element carries the Angular runtime with it, so it is heavier than a hand-written element.

for a middle

Explain why one build per widget duplicates the framework and the root injector, and why zoneless is the v21+ default.

for a senior

Plan the embed: one elements bundle and application injector, trimmed providers and imports, and a file-naming strategy the CMS can follow.

for a principal

Weigh Angular Elements against an iframe or framework-free elements per widget, using measured bundle size and team ownership rather than habit.

## Why a custom element built with Angular is not free `createCustomElement()` produces a small class, but that class drives a real Angular application underneath. To run on a page that has no Angular, the widget's JavaScript must contain: - the Angular **runtime** it uses - `@angular/core` (rendering, change detection, dependency injection) and `@angular/platform-browser`; - `@angular/elements` itself (the element class and its default strategy, which uses a few RxJS operators internally); - every package the component or its services import: `@angular/common/http`, `@angular/forms`, `@angular/router` if it is ever pulled in; - the component code and its compiled templates and styles. The AOT compiler and the bundler's tree-shaking remove unused framework code, so a small widget does not ship all of Angular, but it always ships a framework core that a hand-written element would not need. That is the price of writing the widget with Angular's component model. ## The duplication trap: one build per widget Teams often create one Angular project per widget and give the CMS several script tags. That multiplies cost in three ways: | Aspect | One build per widget | One build for all widgets | |---|---|---| | Framework code downloaded and parsed | once per widget | once | | Applications and root injectors | one per widget | one | | Root services (`providedIn: 'root'`) | separate instance per widget | shared | | Change-detection scheduler | one per application | one | | Version skew between widgets | possible | impossible | Beyond size, separate applications cannot share an authenticated `HttpClient` setup, a cache service, or state in a shared service - each widget sees its own root injector. The better layout is a **single elements bundle**: one `createApplication()` with the providers the widgets need, and one `createCustomElement` + `customElements.define` per widget, all using `appRef.injector`. ## Zone.js and the host page Since **Angular v21** applications are **zoneless by default**: Angular does not provide a zone.js-based change-detection scheduler unless `provideZoneChangeDetection()` is added. Angular Elements works with that: when a page changes an input through an attribute or property, the element calls `setInput`, and if that dirtied the view it marks it for refresh and notifies Angular's scheduler, which schedules a check. Staying zoneless therefore: - removes the `zone.js` polyfill from what the page loads; - avoids patching the **host page's** global APIs (timers, promises, event listeners), which zone.js does to track async work and which can surprise the CMS's own scripts. A widget that still relies on zone-based change detection - state mutated in plain callbacks without signals or `markForCheck` - has to either be fixed or opt back in, and then the polyfill and its patching come back. ## Trimming what the widget pulls in 1. **Audit imports.** A utility imported from a large package drags that package in; the build's bundle-size budgets and stats output show what is inside. 2. **Provide only what is used.** Add `provideHttpClient()` if a widget makes requests; do not copy a whole application's provider list. 3. **Avoid the legacy `@angular/animations` package** for new widgets (deprecated since v20.2); CSS-based enter and leave animations cost less. 4. **Defer heavy parts** of a widget's template that most visitors never open, rather than shipping them in the first chunk. 5. **Load the bundle only on pages that use the tag**, so the CMS does not add framework cost to every page. ## Embedding the files The application builder emits ES modules, so the CMS includes the entry point with `<script type="module" src="...">` plus the global stylesheet if the widgets rely on one. File naming is a trade-off: - The `ng new` production configuration sets `outputHashing` to `'all'`, giving content-hashed names that cache forever but change on every release, so the CMS needs a way to learn the new names. - Setting `outputHashing` to `'none'` gives stable URLs the CMS can hard-code, at the cost of relying on HTTP cache headers for updates. ## Styles are part of the cost too With the default `Emulated` encapsulation, component styles are added to the document head and scoped by generated attributes: they do not leak out, but the host page's CSS can still reach into the widget. `ViewEncapsulation.ShadowDom` isolates in both directions at the price of shadow-root semantics; choosing between them is a styling decision. ## When Angular Elements is the wrong tool - **Inside an Angular application**, use the component directly; wrapping it as an element adds a DOM-event boundary for nothing. - **For one tiny widget** on a performance-sensitive page, the framework core may outweigh the widget; a framework-free element or an iframe may be cheaper. - **Several teams on different Angular majors** each ship their own runtime; that is a deliberate isolation choice, and its cost should be measured, not assumed.

  • Two Angular Elements widgets from separate builds both inject a root-provided CartService; do they share the cart?
    No. Each build calls its own `createApplication()`, so each has its own root injector and its own `CartService` instance, and the two bundles do not even share Angular's code. To share it, register both widgets from one bundle and one application injector, or move the shared state behind the server or a browser-level channel.
  • What does a widget need to change before dropping zone.js if it was written for zone-based change detection?
    Anything that mutates plain fields inside timers, promise callbacks or third-party callbacks and relies on zone.js to trigger a check must instead update signals, call `markForCheck()`, or route the change through an input or template event. Inputs set by the page already schedule a check through the element.

saying these in an interview costs you the question

  • An Angular custom element ships only its own component code
  • The browser deduplicates Angular when two widget bundles load
  • Separately built widgets share providedIn root services on one page
  • Angular Elements always requires zone.js on the host page
  • ShadowDom encapsulation reduces the JavaScript a widget ships