DEV Community

Krzysztof Platis
Krzysztof Platis

Posted on • Updated on

How Angular 14 SSR works under the hood - source code analysis 🕵️

To handle each SSR request, Angular creates a separate, fresh PlatformRef with its own platform’s Injector. Then the platform bootstraps the app module, which mounts the app component into the DOM (not real DOM, but DOM representation on the server, driven by the domino DOM adapter). Recursively, all child components are also mounted. And when the app is stable (when all async tasks are finished, e.g. http calls or setTimeouts), the DOM representation is serialized to a string and sent back in the response to the client. Then the PlatformRef is destroyed. This causes cascade: destroying the app module and the app's root Injector, all the services (also calling their ngOnDestroy hook) and destroying the root component and recursively it's child components with their relevant DOM nodes (also calling their ngOnDestroy hook).

Disclaimer: This article is based on the source code of Angular v14.2.7. The source code for other versions may differ.

Setting up Angular ExpressJS engine

When initializing the ExpressJS app (likely in the server.ts file), we invoke once the ngExpressEngine() function (from @nguniversal/express-engine). Internally it creates just one instance of the Angular's class CommonEngine for the whole NodeJs process. Later, all requests will be handled by the same shared CommonEngine.

For handling each SSR request, the method SharedEngine.render() is called and when the returned Promise resolves, the result HTML is returned in response to the client by passing the HTML to a special callback of ExpressJS.

Rendering for the request

But how the result HTML is produced?

The method CommonEngine.render() calls inside the public function renderModule() (from @angular/platform-server).

Side note: it was surprising for me that the essence of the server side rendering happens in the Angular's package @angular/platform-server, but not in the @nguniversal/express-engine; the latter is just a thin adapter for plugging the Angular's rendering into the ExpressJS server).

Bootstrapping the app

The function renderModule() (from @angular/platform-server) creates a fresh PlatformRef, which on creation calls createPlatformInjector() that creates it's own platform's Injector.

Then the app module is bootstrapped in this PlatformRef, by calling platformRef.bootstrapModule(module).

Side note: The phase of bootstrapping the app is very similar in the client side Angular: platformBrowser().bootstrapModule(AppModule) (likely in main.ts file) - it just uses a different platform object. But on the server, there might be many requests handled in parallel, and therefore many platforms (and their app modules) instantiated in parallel.

The method .bootstrapModule(module)
runs and awaits all the asynchronous APP_INITIALIZER hooks (by calling the method ApplicationInitStatus.runInitializers()). Then it synchronously bootstraps the app component, which causes attaching it (and recursively it’s child components) into the DOM (however it's not a real DOM, but only a DOM representation on the server, driven by the DominoAdapter class). Now, since all the APP_INITIALIZERs completed and all the components were rendered for the first time the app is considered as fully bootstrapped.

Waiting for the app to be stable

The Promise returned by platformRef.renderModule(module) (which resolves when the app is fully bootstrapped) is passed into a function _render(). This function waits for this Promise to resolve. And then it waits until the application becomes stable, by subscribing to the observable ApplicationRef.isStable and awaiting the first emitted true value. It will emit true, when all the pending asynchronous tasks in the app are completed (e.g. http calls to a backend API, setTimeouts, etc.).

Side note: loading some data from backend via a http call to might result in updating some components and displaying important data on the page. Thats why Angular SSR waits for all async tasks to complete, to be sure that the app is stable. Only then the final HTML will look good.

Serializing the app into a string HTML

Then the DOM representation is serialized into a string HTML via the method PlatformState.renderToString().

Side note: we can hook into the moment before the app is serialized, by providing the public InjectionToken BEFORE_APP_SERIALIZED. For example, the TransferState module uses this hook to embed a JSON state as a <script> tag into the document just before the app's serialization.

Destroying the app

When the HTML response is ready, the instance of the platform (and it’s app) is not needed anymore. So then the PlatformRef is destroyed. And this causes cascade: destroying the app module and the app's root Injector, which causes destroying all the services (also calling their ngOnDestroy hook) and eventually destroying the rendered root component and recursively destroying it's components subtree, with their relevant DOM nodes (also calling ngOnDestroy hook for them).

Garbage Collector cleaning up the memory

Now all the objects created by the app are unused (unless the app had a memory leak). And after some time, Garbage Collector will clean up all those objects from the memory of the NodeJS process.

Practical conclusions

Knowing how Angular SSR works under the hood, we can deduce a few practical conclusions:

Hanging app instance causes a memory leak

If the app has some forever pending async task (e.g. http call to a backend API that never responds), then the observable ApplicationRef.isStable will never emit true value. Therefore the rendering will never complete, so the client will never get a response. Moreover, the platform and the app will never be destroyed, and Garbage Collector will never clean up objects created by such an app. This causes a memory leak by itself. If your SSR never ends and you have no clue which pending async task causes it, see: How to find out why Angular SSR hangs - track NgZone tasks 🐾 .

Sharing global objects in between parallel app instances is prone to race condition

When the app’s logic depends on some mutable global object (e.g. a global variable or a static property of a class), and when many applications are rendered in parallel on the server, they share the same global variable in the NodeJS process and are prone to race conditions when reading/writing to such a variable. For more, see: Don’t use global static objects - avoid race condition in SSR Angular 🏎

Forgetting to unsubscribe from an observable can cause the server process to crash

If any of the components subscribes to some data source (e.g. to a RxJs observable), but doesn’t unsubscribe (e.g. in ngOnDestroy hook), then even after destroying the whole app, such a component will be considered as in use and the Garbage Collector will never clean it up. Therefore, even after the application is formally destroyed, the memory allocated for this component object is never released. It’s a memory leak. And going on, the more SSR requests and rendered apps with such a component’s logic, the more memory will be allocated in the NodeJs process and never released. Eventually, when the NodeJS process is out of memory, it will crash.

Singleton services can also cause memory leaks

The same holds for services. If you subscribe to an observable in the service, make sure to unsubscribe later, e.g. in ngOnDestroy method of the service. Although it doesn’t happen in the browser, on the server the ngOnDestroy hook will be called on each service, when the application is destroyed. For more, see: ngOnDestroy in services - unsubscribe to avoid memory leaks in SSR Angular 💧

Top comments (1)

Collapse
 
ayyash profile image
Ayyash

impressive, you rewrote code with human language :)
regarding the memory leak due to subscription, does that apply to cold observables as well?