How Angular 14 SSR works under the hood - source code analysis đ”ïž
Krzysztof Platis
Posted on October 20, 2022
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 setTimeout
s), 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_INITIALIZER
s 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, setTimeout
s, 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 đ§
If you really feel like buying me a coffee
... then feel free to do it. Many thanks! đ
Posted on October 20, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.