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, Emscripten MODULARIZE, 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.

A Wasm module behind an Angular service The .wasm file is copied to the build output as an asset. A root-provided service initialises the module lazily on first use and caches the promise. Components inject the service and call typed methods. Heavy calls are delegated to a web worker generated by the Angular CLI, with results exposed as signals. .wasm as asset angular.json assets WasmService (root) lazy init, cached promise components inject it typed methods heavy calls → worker ng generate web-worker results as signals template updates

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.

Wasm on Angular's main thread versus in a CLI-generated worker On the main thread, long Wasm calls block input handling and change detection, making the UI freeze. In a worker generated by the Angular CLI, the same service API posts work to the worker, keeping the UI responsive at the cost of message passing. main thread simplest wiring blocks input + rendering fine for calls < ~10 ms light work web worker ng generate web-worker UI stays responsive transfer buffers to avoid copies heavy work

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 .wasm like 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.

Longest main-thread task while resizing 20 images Milliseconds for the longest main-thread task during a batch resize of 20 photos, with the Wasm module on the main thread and in an Angular CLI-generated web worker. ms (longest task) Wasm on main thread 1,400 ms Wasm in web worker 45 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.

← Back to Full-Stack Frameworks with Wasm