The Telltale Sign: Involuntary Context Switches

Production servers have a knack for misbehaving in ways that standard observability stacks simply don't surface. CPU utilization appears healthy, memory usage is stable, error rates remain low — yet the user experience deteriorates. Response times inflate under pressure, the event loop grinds, and there’s no obvious culprit.

This is the account of how we chased precisely that phantom across a high-traffic Angular SSR storefront, the obscure OS-level statistic that finally provided a clear indicator, and the unexpectedly intricate path we had to navigate to integrate that signal into our system.

Consider this a field guide. Every obstacle we encountered — and there were six, spread across six distinct layers of the Angular SSR stack — applies to any Node.js native addon you intend to load in an Angular 17+ SSR application. Whether you’re integrating Bluetooth hardware, hardware-accelerated cryptography, graphics libraries, or any other compiled native module into your Angular server, this guide maps each error you’ll face, in the order you’ll face it, along with the precise solution.


The Anomaly: Diagnosing an Invisible Issue

Our project is a multi-tenant e-commerce platform built on Angular 17+ with server-side rendering. The SSR server handles every request, composing Angular components on the server, embedding data, and streaming HTML to the client. Under typical traffic, it’s fast and reliable. But under spikes, or following certain releases, it would display a subtle sluggishness that defied easy identification.

Conventional APM tools — Datadog, New Relic, Azure Application Insights — delivered the standard metrics: latency, CPU load, memory consumption, error count. All were within normal bounds. Nothing stood out.

What those tools omitted was an insight into the health of the server process itself at the OS level. Specifically: was Node.js receiving CPU time when it needed it, or was the kernel constantly interrupting it?

The missing metric: involuntary context switches

Every process shares the CPU with its peers. The OS scheduler dictates which process executes at any instant, using context switches to pause one process, preserve its state, and grant the CPU to another.

There are two variants. A voluntary context switch occurs when a process consciously cedes the CPU — for instance, while awaiting a network reply, a disk read, a timer, or any I/O event. This is typical for a Node.js server. The event loop spends most of its existence waiting, and each await effectively represents a voluntary yield.

An involuntary context switch happens when the OS forcibly seizes the CPU — because the process’s time slice expired, because a higher-priority task demanded the CPU, or because the system is overloaded enough that the scheduler has to juggle more aggressively. These are the significant ones. A high count of involuntary switches relative to voluntary ones signals that the process is not getting CPU time on demand — it’s being evicted and waiting for a restart. That results in event loop delays, extended response times, and that hard-to-pinpoint "feels wrong" sensation.

The ratio of involuntary to voluntary context switches above roughly 5% serves as a practical benchmark for "worth investigating." Not an absolute threshold — your baseline depends on your workload and hardware — but a steady relative indicator. If this ratio rises during a deployment or a load spike, something has shifted.

The kernel makes this data available via the getrusage POSIX system call, which exists on Linux and macOS. Standard APM agents don’t gather it. To access it, you have to work closer to the system.


The Case for a Native Addon

There’s no pure JavaScript method to invoke getrusage. It’s a C system call. To leverage it from Node.js, you require a native addon — a compiled shared library, specifically a .node file, which Node.js can load and execute.

Native addons are more pervasive than one might assume. Some notable examples:

  • Hardware and OS integration — access to serial ports, USB devices, GPIO pins on embedded systems, and direct socket management
  • Cryptography — bindings to OpenSSL or platform-specific crypto for tasks where JavaScript implementations are too slow or fail FIPS compliance
  • Image and media processing — bindings to libvips, ImageMagick, or FFmpeg for server-side image conversion
  • Database clients — certain database connectors leverage native addons to reduce JavaScript overhead for high-throughput transactions
  • Process monitoring — reading OS-level process metrics like CPU time, memory maps, file descriptor counts, and of course, context switches

Across all these scenarios, the pattern is uniform: the OS or a native library offers a capability JavaScript can’t reach directly, and a native addon acts as the conduit.

For our case, the objective was a lightweight supervisor that invokes getrusage on a fixed interval and reports context switch counts back to JavaScript, where we could compute the ratio and trigger an alert to Rollbar if it crossed our threshold.


Mapping the SSR Pipeline Before You Modify It

Before tackling the issues, here’s a breakdown of how Angular 17+’s SSR toolchain processes your server code. Each of the six problems originates at a different phase. Without this overview, the errors would seem random.

When you execute nx serve or nx build on an Angular SSR app, your code transitions through four distinct contexts:

┌─────────────────────────────────────────────────────────────────┐
│                        nx build / nx serve                       │
└───────────────────────────────┬─────────────────────────────────┘

              ┌─────────────────▼──────────────────┐
1. esbuild (build time)      │
              │                                      │
              │  Compiles server TypeScript + deps   │
              │  into server.mjs. Resolves imports,  │
              │  bundles assets. Static — no runtime │
              │  knowledge.                          │
              └─────────────────┬────────────────────┘

               nx serve?        │        nx build?
          ┌─────────────────────┼──────────────────────┐
          │                     │                       │
┌─────────▼──────────┐ ┌───────▼────────────┐ ┌───────▼──────────────┐
2. Vite dev server │ │ 3. Prerender worker│ │  4. Node.js runtime  │
│    (serve only)     │ │  (build only)      │ │    (production)      │
│                     │ │                    │ │                      │
│ Intercepts module   │ │ Boots server.mjs   │ │ Runs server.mjs      │
│ loading at runtime  │ │ in a worker thread │ │ directly. No Vite,   │
│ for HMR. Pre-       │ │ to extract routes  │ │ no bundler, just     │
│ bundles deps into   │ │ and pre-render     │ │ Node.js + dlopen.    │
│ its own cache.      │ │ HTML at build time.│ │                      │
└─────────────────────┘ └────────────────────┘ └──────────────────────┘

A native .node addon must endure all four of these contexts. That’s precisely why there are six problems.


The Addon and Its Supporting Tools

procstat-napi

procstat-napi is the addon built for this. It encapsulates getrusage(RUSAGE_SELF, ...) through the N-API, the stable, ABI-versioned C API Node.js exposes for native addon development. N-API addons compile once and function across Node.js versions without recompilation, since the API is versioned and backward-compatible.

Internally, the addon uses a uv_timer_t — a timer handle from libuv, the async I/O library that underpins Node.js’s event loop — to call getrusage at a configurable interval and pass the results back to JavaScript:

import { createMonitor } from 'procstat-napi';

const monitor = createMonitor({ intervalMs: 1000 });

monitor.on('stats', (stats) => {
  const ratio = stats.involuntaryContextSwitches /
    (stats.voluntaryContextSwitches + stats.involuntaryContextSwitches);
  console.log(`Involuntary ratio: ${(ratio * 100).toFixed(1)}%`);
});

The API is intentionally minimal: on(event, callback) and off(event, callback). The addon also integrates with AddressSanitizer (ASan) for memory leak notifications, which we’ll revisit in Problem 0.

Distribution: prebuildify

Native addons require compilation from C++ source. Forcing every user to compile during installation demands a C++ toolchain and extends npm install time. The smarter method is to pre-compile binaries for each supported platform and bundle them inside the npm package.

prebuildify handles this. Running prebuildify --napi yields a prebuilds/ folder containing platform-specific .node binaries — for example, prebuilds/linux-x64+ia32/procstat-napi.node. Users receive pre-built binaries, skipping any compilation step.

Loading: node-gyp-build-esm

The standard runtime loader for prebuildify-generated binaries is node-gyp-build, but it has a significant limitation in modern bundler environments: it builds the .node file path dynamically at runtime. No bundler — whether esbuild, webpack, or Rollup — can statically analyze a path that doesn’t exist until execution. The binary stays invisible to the build tool.

node-gyp-build has also remained unmaintained since late 2024. That prompted the fork into node-gyp-build-esm: a dual-format (CJS + ESM) replacement that introduces a prebuilds map — a factory function where each require() references a static, predetermined path:

import { load } from 'node-gyp-build-esm';

const binding = load(import.meta.dirname, () => ({
  'linux-x64': () => require('./prebuilds/linux-x64+ia32/procstat-napi.node'),
  'darwin-x64': () => require('./prebuilds/darwin-x64+arm64/procstat-napi.node'),
  'win32-x64':  () => require('./prebuilds/win32-x64+ia32/procstat-napi.node'),
}));

The factory executes lazily — only the thunk for the matching platform runs. esbuild can detect all three require() calls at build time, copy the binaries to the output directory, and rewrite the paths. This static analyzability forms the backbone that enables everything else.

With the addon authored, prebuilds compiled, and the loader ready, it was time to integrate it into the Angular SSR application. Here’s every issue that surfaced thereafter.


Problem 0 — Addressing ASan First

Before any Angular-specific issues, there was a prerequisite unique to this addon: procstat-napi is built with AddressSanitizer (ASan) enabled. ASan is a memory error detector built into Clang and GCC — it instruments memory allocations and accesses at compile time to catch bugs like use-after-free and buffer overflows, and it can relay leak reports back to JavaScript via the addon’s "leak" event.

ASan has one strict requirement: its runtime library (libasan.so on Linux) must be the very first library loaded into the process. It needs to intercept the system memory allocator from process startup. Node.js doesn’t link against libasan.so, so when Node.js calls dlopen — the POSIX system call that loads a shared library into a running process — to open the .node addon, ASan finds itself arriving too late and aborts immediately:

ASan runtime does not come first in initial library list;
you should either link runtime to your application or
manually preload it with LD_PRELOAD.

The solution is LD_PRELOAD, an environment variable that the Linux dynamic linker reads before starting any process. Libraries listed there are loaded ahead of everything else:

LD_PRELOAD=$(gcc -print-file-name=libasan.so) node dist/.../server.mjs

gcc -print-file-name=libasan.so resolves the correct absolute path for the current compiler version portably — more reliable than hardcoding a path like /usr/lib/x86_64-linux-gnu/libasan.so.6. This is required for both nx serve and production. In a container, it belongs in your CMD or entrypoint script.

In startThreadMonitor, we actually use LD_PRELOAD as a guard condition before even attempting to load the addon:

if (!global_isServeMode && process.env['LD_PRELOAD']?.includes('asan')) {
  const { createMonitor } = await import('procstat-napi');
  // ...
}

This is deliberate and self-defensive: if an environment is deployed without LD_PRELOAD, the monitor simply doesn’t start rather than crashing the server.

Applies to: Any addon compiled with AddressSanitizer. If your addon doesn’t use ASan, skip this step.


Problem 1 — esbuild’s Ignorance of .node Files

With LD_PRELOAD configured, the next move was nx serve. The first esbuild error appeared right away:

No loader is configured for ".node" files:
  node_modules/procstat-napi/prebuilds/linux-x64+ia32/procstat-napi.node
    node_modules/procstat-napi/index.mjs:24:27:
      24'./prebuilds/linux-x64+ia32/procstat-napi.node',

The prebuilds map had fulfilled its purpose — esbuild followed the static require() string and located the binary. It just had no clue how to process a compiled native library. esbuild handles JavaScript, TypeScript, CSS, and JSON. A .node file falls entirely outside its scope.

The solution is a plugin, originally shared in esbuild issue #1051. It redirects .node files through a virtual namespace, creates a small runtime wrapper, and uses esbuild’s built-in file loader to copy the binary to the output directory:

import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

const setupNativeNodeModulesPlugin = () => ({
  name: 'native-node-modules',
  setup(build) {
    if (build.initialOptions.platform !== 'node') return;

    // Resolve .node imports to absolute paths and move them into
    // the "node-file" virtual namespace for custom loading.
    build.onResolve({ filter: /\.node$/, namespace: 'file' }, (args) => ({
      path: require.resolve(args.path, { paths: [args.resolveDir] }),
      namespace: 'node-file',
    }));

    // Emit a small wrapper that requires the .node file at runtime
    // using the path esbuild copies it to in the output directory.
    build.onLoad({ filter: /.*/, namespace: 'node-file' }, (args) => ({
      contents: `
        import path from ${JSON.stringify(args.path)}
        try { module.exports = require(path) }
        catch {}
      `,
    }));

    // Hand .node files back to the "file" namespace so esbuild's
    // default file loader copies them to the output directory.
    build.onResolve({ filter: /\.node$/, namespace: 'node-file' }, (args) => ({
      path: args.path,
      namespace: 'file',
    }));

    const opts = build.initialOptions;
    opts.loader = opts.loader || {};
    opts.loader['.node'] = 'file';
  },
});

export default setupNativeNodeModulesPlugin;

Register this plugin via the plugins option in angular.json, which has been available since Angular 17 for the application builder.


Problem 2 — The Plugin Alone Isn’t Sufficient

The plugin didn’t solely clear the error. The missing element was externalDependencies in project.json:

"executor": "@nx/angular:application",
"options": {
  "externalDependencies": [
    "./prebuilds/linux-x64+ia32/procstat-napi.node",
    "./prebuilds/darwin-x64+arm64/procstat-napi.node",
    "./prebuilds/win32-x64+ia32/procstat-napi.node"
  ]
}

This directly aligns with esbuild’s external configuration on the server bundle. When a path is marked external, esbuild ceases to process it and preserves the require() call unchanged in the output. The .node file is then resolved at runtime by Node.js’s native module loader, which invokes dlopen and knows exactly what to do.

The plugin and externalDependencies fulfill distinct roles, and both are essential. The plugin teaches esbuild to copy .node files to the output directory when it encounters them via its standard resolution path. The external config is the definitive guarantee that esbuild never attempts to bundle or transform those paths, regardless of how it encounters them.


Issue 3 — Relative Paths Break Under Vite’s Module Resolution

Once the esbuild settings were aligned, nx serve compiled cleanly. However, loading localhost triggered this output in the console:

[vite] (ssr) Error when evaluating SSR module ./server.mjs:
Cannot find module './prebuilds/linux-x64+ia32/procstat-napi.node'
Require stack:
- /home/project/.angular/cache/21.2.3/ng21-test/vite/deps_ssr/chunk-6DU2HRTW.js

This failure occurs in a distinct environment. When using nx serve, Angular doesn’t execute the esbuild-produced server.mjs directly. Instead, it leverages Vite as the dev server to enable HMR. Vite installs a Node.js module customization hook — a feature available since Node.js 20 that allows tooling to intercept how modules are resolved:

function customizationHookResolve(specifier, context, nextResolve) {
  if (specifier.startsWith(customizationHookNamespace)) {
    let data = specifier.slice(42),
      [parsedSpecifier, parsedImporter] = JSON.parse(data);
    specifier = parsedSpecifier;
    context.parentURL = parsedImporter;
  }
  return nextResolve(specifier, context);
}
module = (await import('node:module')).Module;
module.registerHooks({ resolve: customizationHookResolve });

Vite re-bundles procstat-napi into its own cache located at .angular/cache/.../vite/deps_ssr/chunk-6DU2HRTW.js. From that vantage point, the relative reference ./prebuilds/linux-x64+ia32/procstat-napi.node leads nowhere — the resolver no longer anchors to the original node_modules/procstat-napi/ directory.

The typical remedy for Vite — designating the package under ssr.external within vite.config.ts — isn’t possible here since Angular’s dev server doesn’t expose a vite.config.ts for configuration.

To get around this, we detect Vite at runtime inside procstat-napi’s index.mjs and fall back to absolute paths when we know it’s present. Vite always injects import.meta.env.MODE and import.meta.env.BASE_URL into modules it processes — bare Node.js execution and esbuild output lack these. We use that as the branching signal:

const isVite =
  typeof import.meta !== 'undefined' &&
  !!import.meta.env?.MODE &&
  !!import.meta.env.BASE_URL;

if (isVite) {
  binding = load(import.meta.dirname, () => ({
    'linux-x64': () =>
      require(join(process.cwd(),
        'node_modules/procstat-napi/prebuilds/linux-x64+ia32/procstat-napi.node')),
    'darwin-x64': () =>
      require(join(process.cwd(),
        'node_modules/procstat-napi/prebuilds/darwin-x64+arm64/procstat-napi.node')),
    'win32-x64': () =>
      require(join(process.cwd(),
        'node_modules/procstat-napi/prebuilds/win32-x64+ia32/procstat-napi.node')),
  }));
}

An absolute path completely circumvents Vite’s resolver, letting Node’s native require pass it straight to dlopen. The process.cwd() anchor presumes Nx starts the dev server from the workspace root — usually a safe bet, though worth verifying if your project is structured differently.


Issue 4 — createMonitor is not a function During Prerendering

Once nx serve ran smoothly, we moved to a production build. Executing with NG_BUILD_MANGLE=0 (which preserves readable names by disabling mangling) brought up:

✘ [ERROR] An error occurred while extracting routes.
createMonitor is not a function

This originates from Angular’s static route discovery phase — a component of the build where Angular boots the compiled server bundle in a Node.js worker thread, traverses all routes, and generates HTML. That worker thread creates an environment distinct from the production server, breaking the bindings for the addon. Since the addon was imported at the top of server.ts, the failure halted the entire route extraction process.

The remedy is to defer monitor initialization — wrapping it in a dynamic import() — and protect it with a try-catch that examines the error stack. When the addon fails within Angular’s prerender worker, the stack includes prerender-root, a marker Angular uses to name its worker thread entry point:

async function startMonitor() {
  try {
    const { createMonitor } = await import('procstat-napi');
    const monitor = createMonitor({ intervalMs: 1000 });

    monitor.on('stats', (stats) => {
      console.log('stats = ', stats);
    });
  } catch (error) {
    if (error instanceof Error && error.stack?.includes('prerender-root')) {
      // We're inside Angular's prerender/route-extraction worker — the addon
      // cannot load here and isn't needed. Swallow silently.
      return;
    }

    throw error;
  }
}

startMonitor();

Instead of preemptively guessing the prerender context, you let the error surface, verify it originates from the expected source, and only discard it then. A legitimate error — missing prebuild, mismatched architecture, corrupted binary — still gets thrown. The prerender-root string is an internal Angular detail that might shift, but if it does, the result is an explicit throw rather than a hidden failure.

There’s also a pre-emptive approach: Angular’s prerender worker sets NG_ALLOWED_HOSTS=localhost in its environment, which can serve as an early-exit flag for the regular path:

async function startMonitor() {
  // NG_ALLOWED_HOSTS is set by Angular's internal render worker — not a stable
  // public API. Used here as a best-effort happy-path heuristic only.
  if (process.env['NG_ALLOWED_HOSTS']) return;

  const { createMonitor } = await import('procstat-napi');
  // ...
}

The downside: if Angular ever stops setting that variable, the guard quietly disappears. The try-catch method, by contrast, fails noisily when its expectations aren’t met.


Issue 5 — Conflicting createRequire Declarations

With the prerender safeguard in place, the production build succeeded — but a runtime error hid in the compiled result. Angular’s application builder adds a preamble snippet at the beginning of every server bundle to emulate require in ESM, since esbuild lacks a require shim for ESM output (see esbuild issue #1921):

// Injected by Angular CLI at the top of server.mjs
import { createRequire } from 'node:module';
globalThis['require'] ??= createRequire(import.meta.url);

This banner gets inserted as plain text outside the module graph — esbuild can’t deduplicate it against other imports. Both node-gyp-build-esm and procstat-napi also pull createRequire from node:module in their own source. In the flattened bundled ESM output, several import { createRequire } from 'node:module' statements sit at the same lexical level — a naming clash that breaks at runtime.

The fix was a single character-level change to Angular CLI, renaming the placed identifier to a namespaced token:

// Before
import { createRequire } from 'node:module';
globalThis['require'] ??= createRequire(import.meta.url);

// After — PR #32765
import { createRequire as __ngCreateRequire } from 'node:module';
globalThis['require'] ??= __ngCreateRequire(import.meta.url);

That patch lives at angular/angular-cli#32765. This isn’t unique to procstat-napi — any native addon or library that imports createRequire explicitly would face the same clash.


Issue 6 — .node Assets End Up in the Wrong Folder

Following a clean build, operating the production server exposed a last problem: the .node binaries were absent at runtime.

Angular’s application builder invokes esbuild in two separate phases — one for the browser bundle and another for the server bundle. The esbuild plugin duplicates .node files into the output directory of whichever phase is active. The browser phase runs first and captures the binaries, placing them at:

dist/apps/APP_NAME/browser/media/procstat-napi-KKPURPS6.node

The server bundle’s compiled require('./media/procstat-napi-KKPURPS6.node') looks for them at a matching relative location from the server output folder — dist/apps/APP_NAME/server/media/ — which is missing.

There’s no method to redirect the file loader’s output path from inside the esbuild plugin. The answer is a script that runs post-build:

// scripts/copy-node-addons/index.js
import fs from 'node:fs';
import path from 'node:path';

import { output, workspaceRoot } from '@nx/devkit';
import { sync } from 'glob';

const appName = process.env.NX_TASK_TARGET_PROJECT;

const addons = sync(
  path.join(workspaceRoot, `dist/apps/${appName}/browser/media/*.node`)
);

if (
  !fs.existsSync(path.join(workspaceRoot, `dist/apps/${appName}/server/media`))
) {
  fs.mkdirSync(
    path.join(workspaceRoot, `dist/apps/${appName}/server/media`),
    { recursive: true }
  );
  output.log({ title: `Created dist/apps/${appName}/server/media folder` });
}

// Native addons are build-target-agnostic — the same binary serves both.
for (const addon of addons) {
  fs.copyFileSync(addon, addon.replace('browser', 'server'));
  output.log({ title: `Copied addon into ${addon.replace('browser', 'server')}` });
}

Hooked into Nx as a build-with-deps target that follows the main build:

"build-with-deps": {
  "executor": "nx:run-commands",
  "dependsOn": [{ "target": "build", "params": "forward" }],
  "options": {
    "commands": ["node scripts/copy-node-addons"]
  }
}

Execute via yarn nx build-with-deps app-name.


What the Monitoring Turned Up

After resolving all six issues, the addon functioned in production. Within the earliest monitoring window, Rollbar delivered this:

voluntaryContextSwitches:   725
involuntaryContextSwitches: 358

A rate of 33% — about six times higher than the 5% cutoff we deemed actionable. The monitoring instantly surfaced a tangible problem.

The cause, once we investigated, was abandoned SSR renders. When a user closes a tab or navigates away mid-request, the client connection drops. But the Node.js server has no inherent way to know the client is gone — it continues rendering the full Angular page, fetching data from APIs, assembling the HTML response, and then discarding it since no one is listening. Each such orphaned render consumes CPU cycles, keeps the event loop occupied, and generates the variety of needless CPU activity that raises involuntary context switch counts.

The solution was abort signal propagation. When the underlying TCP socket closes, an AbortController triggers, which is handed to angularNodeAppEngine.handle() via abortSignal. Inside the Angular app, a function pulls that signal from the request context and tears down the Angular platform when it fires, halting the render mid-way:

// In server.ts — detect client disconnect and abort the render
const abortController = new AbortController();

const onClose = () => {
  if (abortController.signal.aborted) return;
  abortController.abort();
};

req.on('close', onClose);
req.socket.on('close', onClose);

angularNodeAppEngine.handle(req, {
  abortSignal: abortController.signal,
  // ...
});
// In app.component.ts — respond to the abort signal inside Angular
function abortOnPlatformDestroy() {
  const context = inject<any>(REQUEST_CONTEXT);
  const abortSignal: AbortSignal | undefined = context.abortSignal;

  if (abortSignal == null) return;

  const platform = inject(PlatformRef);

  const onAbort = () => {
    if (platform.destroyed) return;
    queueMicrotask(() => {
      if (platform.destroyed) return;
      platform.destroy(); // stops the render, cleans up DI, frees resources
    });
  };

  if (abortSignal.aborted) {
    onAbort();
    return;
  }

  abortSignal.addEventListener('abort', onAbort);
  platform.onDestroy(() => abortSignal.removeEventListener('abort', onAbort));
}

After deploying the abort propagation, the involuntary context switch ratio fell back under the 5% threshold and the Rollbar alerts quieted down. The monitoring flagged a genuine inefficiency, the fix was precise, and the signal proved it worked.

Without the context switch oversight, this issue would have remained invisible, let alone verifiable as resolved. CPU usage stayed within acceptable bounds all along — the problem was subtle enough to evade typical metrics yet substantial enough to impact server behavior under load.


The Full Overview

What began as „call getrusage from Node.js” became a seven-step trek through the Angular SSR ecosystem. The table below serves as a reference — applicable to any N-API addon, not just procstat-napi:

# Error Pipeline stage Fix
0 ASan runtime does not come first OS dynamic linker LD_PRELOAD=$(gcc -print-file-name=libasan.so)
1 No loader configured for ".node" files esbuild build time esbuild native-node-modules plugin
2 Plugin alone not enough Nx/Angular builder config externalDependencies in project.json
3 Cannot find module in dev server Vite SSR module resolver Vite detection heuristic + process.cwd() absolute paths
4 createMonitor is not a function during prerender Angular prerender worker try-catch with prerender-root stack check + dynamic import()
5 Duplicate createRequire binding collision Angular CLI banner injection PR #32765 to Angular CLI
6 .node files in browser/media/, not server/media/ esbuild dual-pass output Post-build copy script

Issue 0 applies only if your addon uses ASan compilation. Issues 1–6 affect any N-API addon within an Angular 17+ SSR app that relies on @angular/ssr. These fixes are separate — apply each one as you encounter the corresponding error.

None of this appeared in any documentation. Understanding it required digging through Angular CLI, Vite, and esbuild internals. One issue demanded patching Angular CLI itself. The investment paid off: the monitoring caught a real production problem on its first run — something standard APM had been overlooking.

The hope is that this write-up gives the next developer bringing a native addon into an Angular SSR project a map before they start.