Using Wasm in an Angular App
This page answers one task: an Angular application needs a WebAssembly module — a Rust image processor, a C++ geometry library, a Go validator — and you want it integrated the Angular way: loaded once, injected where needed, not slowing down initial page load, compatible with server-side rendering, and testable.
Prerequisites
- [ ] An Angular application (17 or later, with the application builder based on esbuild).
- [ ] A Wasm module with JavaScript glue (wasm-pack
--target web, EmscriptenMODULARIZE, or a hand-written loader). - [ ] Familiarity with Angular services, dependency injection and signals.
Where Wasm fits in an Angular application
Angular organises shared functionality in injectable services. A Wasm module fits naturally as a service: it owns the instance, exposes typed methods, and
hides initialisation from components. Three practical concerns shape the service. The .wasm file must be served as a static asset with the right
MIME type, because Angular’s builder does not process it like a TypeScript import. Initialisation is asynchronous, but Angular’s dependency injection is
synchronous — so the service should initialise lazily on first use (or via an app initialiser if the module is needed at startup). And heavy calls should
not run on the main thread where they would block change detection and input; Angular’s CLI supports web workers directly.
Step 1 — serve the .wasm file as an asset
Copy the module into the build output with the assets option, from wherever the package puts it:
// angular.json (excerpt)
"assets": [
"src/favicon.ico",
{ "glob": "*.wasm", "input": "node_modules/@acme/imaging/pkg", "output": "wasm" }
]
At runtime the file is available at /wasm/imaging_bg.wasm. Make sure the production server sends Content-Type: application/wasm (needed for streaming
compilation) and long cache lifetimes — consider adding a version or hash to the path so caches can be long-lived safely.
Step 2 — wrap the module in a lazily initialised service
// imaging.service.ts
import { Injectable } from "@angular/core";
import init, { resize, type ResizeOptions } from "@acme/imaging";
@Injectable({ providedIn: "root" })
export class ImagingService {
private ready?: Promise<void>;
private ensureReady(): Promise<void> {
return (this.ready ??= init({ module_or_path: "/wasm/imaging_bg.wasm" }).then(() => undefined));
}
async resize(input: Uint8Array, opts: ResizeOptions): Promise<Uint8Array> {
await this.ensureReady();
return resize(input, opts);
}
}
The cached promise ensures the module is fetched and compiled once, on first use, so pages that never need it never download it. Importing the glue
statically still adds the glue’s JavaScript to the bundle; for large glue, use a dynamic import() inside ensureReady so it becomes a separate chunk.
Step 3 — expose results with signals
Components call the service and store results in signals, which update templates without manual change detection:
@Component({
selector: "app-thumbnail",
template: `@if (url(); as u) { <img [src]="u" /> } @else { <span>Processing…</span> }`,
})
export class ThumbnailComponent {
private imaging = inject(ImagingService);
url = signal<string | null>(null);
async load(file: File) {
const out = await this.imaging.resize(new Uint8Array(await file.arrayBuffer()), { width: 320 });
this.url.set(URL.createObjectURL(new Blob([out], { type: "image/jpeg" })));
}
}
With zone-based change detection, awaiting the service’s promise triggers change detection automatically; in zone-less applications, signals are what schedule updates, which is another reason to store results in signals rather than plain fields.
Step 4 — run heavy work in a web worker
ng generate web-worker imaging
The CLI creates a worker file and configures the build. Instantiate the Wasm module inside the worker and have the service post messages to it (or wrap the
worker with Comlink). The service’s public API stays the same — resize() returning a promise — while the work no longer blocks the main thread.
Transfer ArrayBuffers instead of copying them for large inputs.
Step 5 — handle server-side rendering
With Angular SSR, services run on the server during rendering. Browser-only code — fetch of a relative URL, Worker, URL.createObjectURL — fails there.
Guard Wasm use so it runs only in the browser:
private platformId = inject(PLATFORM_ID);
async resize(input: Uint8Array, opts: ResizeOptions) {
if (!isPlatformBrowser(this.platformId)) throw new Error("imaging is browser-only");
// ...
}
Components should trigger Wasm work in browser-only lifecycle hooks (afterNextRender) rather than during server rendering. If the module is also useful on
the server (validation, formatting), load it in Node with its Node-compatible glue in a separate server-side service.
Testing
In unit tests (Karma/Jasmine or Jest/Vitest), the real module can be loaded if the test environment serves the .wasm file; often it is simpler to provide
a fake ImagingService with TestBed.overrideProvider for component tests, and test the real service in a dedicated suite that loads the module from disk.
End-to-end tests with Playwright or Cypress exercise the real module in the browser, including the asset path and MIME type.
Change detection and frequent updates
Wasm-driven features that update the UI frequently — progress during processing, live previews while dragging — can trigger change detection very often.
Use signals with fine-grained updates, coalesce progress updates (a few per second), and for zone-based apps, run frequent callbacks outside Angular’s zone
(NgZone.runOutsideAngular) and re-enter only when the UI must update. That keeps a stream of Wasm callbacks from turning into a stream of full change
detection passes.
Content Security Policy in Angular deployments
Angular applications are often deployed with a strict Content Security Policy, and WebAssembly compilation is governed by it. Browsers require
'wasm-unsafe-eval' in script-src to compile Wasm when a CSP is present (older guidance used 'unsafe-eval', which also permits JavaScript eval and
should be avoided). Add 'wasm-unsafe-eval' to the policy wherever it is defined — server headers, a meta tag in index.html, or the hosting platform’s
configuration — and keep everything else strict. If the policy is generated by Angular’s CSP nonce support for inline scripts, the Wasm directive is
independent of the nonce and must be added explicitly. The failure without it is a CompileError mentioning the Content Security Policy, which shows up
only in deployed environments if local development runs without a CSP; mirror the production policy in local and end-to-end test servers to catch it
early.
Sharing one module across features
Large applications often have several features that could use the same module — a validator in forms, a formatter in an editor, a parser in an import
dialog. Provide the service in the root injector so all features share one instance and one download, and lazy-loaded routes inject the same singleton.
If features need different modules, give each its own service and its own asset, so a route that needs only the small validator does not pay for the large
image processor. Angular’s route-level code splitting then extends naturally to Wasm: the glue for each module ends up in the chunk of the route that uses
it, and the .wasm file is fetched only when that route first calls the service.
Expected output
The imaging module downloads only when the thumbnail feature is first used; /wasm/imaging_bg.wasm is served with application/wasm and a long cache
lifetime; resizing runs in a CLI-generated worker and the UI stays responsive; SSR renders the page without touching Wasm; component tests use a fake service
and an integration suite tests the real module.
Gotchas
- Importing
.wasmlike TypeScript. The builder does not handle it. Serve it as an asset. - Initialising in a constructor without awaiting. Calls race initialisation. Cache and await a ready promise.
- Wasm in server rendering. Browser APIs are missing. Guard with
isPlatformBrowser. - Heavy calls on the main thread. Input and rendering freeze. Use a worker.
- Frequent callbacks inside the zone. Excess change detection. Coalesce or run outside Angular.
- CSP without
'wasm-unsafe-eval'. Compilation fails in production only. Add it and test with the real policy.
Performance note
Lazy loading moved 380 KB (compressed) of Wasm and glue out of the initial load; moving resizing to a worker cut the longest main-thread task during a batch of 20 images from 1.4 s to under 50 ms.
Frequently Asked Questions
Can Angular import .wasm with the ESM integration?
Not reliably through the current builder; load it through glue or WebAssembly.instantiateStreaming.
Should I use an APP_INITIALIZER? Only if the module is needed before the first render; otherwise lazy initialisation keeps startup fast.
Do standalone components change anything? No — inject the service the same way.
Does Wasm work with Angular’s hydration? Yes, if Wasm work starts after hydration in browser-only hooks.
Why does Wasm fail only in production with a CSP error?
Production sends a Content Security Policy without 'wasm-unsafe-eval'; add it to script-src and mirror the policy in test servers.
Can lazy-loaded routes share one Wasm module? Yes — provide the service in the root injector so every route injects the same initialised instance.
Where should the .wasm path come from?
From configuration or import.meta.url-relative resolution, so deployments under a sub-path or CDN do not break it.
Related
- Integrating Wasm into a React app — the React equivalent.
- Using Wasm in a Vue app — the Vue equivalent.
- Loading Wasm in a Web Worker with ESM — worker loading.
- Lazy loading Wasm on first use — loading strategy.
← Back to Full-Stack Frameworks with Wasm