DEV Community

Marco Sbragi
Marco Sbragi

Posted on

Angular Bootstrap

Must buy more strong laces

Why You Should "Pull Your App Up by Its Own Bootstraps"

The English idiom "to pull oneself up by one's bootstraps" (in Italian: tirarsi su per le stringhe degli stivali) describes an impossible task: lifting yourself off the ground by pulling your own laces. In software engineering, however, bootstrapping is very real. It’s that critical moment where an application loads its configuration, sets up its environment, and prepares itself for the user—all before the first component even renders.

The Standard Approach: APP_INITIALIZER

Standard literature often suggests using the APP_INITIALIZER token. This allows you to hook into the Angular bootstrap process and run a function (usually returning a Promise or Observable) before the app finishes initializing. It looks something like this:

// The "Standard" Way
{
  provide: APP_INITIALIZER,
  useFactory: (configService: ConfigService) => () => configService.loadConfig(),
  deps: [ConfigService],
  multi: true
}

Enter fullscreen mode Exit fullscreen mode

While functional, this happens inside the Angular lifecycle. This means your services and components are already being instantiated by the DI (Dependency Injection) container. If your configuration is slow or fails, you can run into "flickering" UI states or race conditions where a service tries to access a config value that hasn't arrived yet.

A Smarter Approach: The "Hot Config" Method

In my work with the RAD Framework, I’ve refined a method that makes the application "smart" from millisecond zero by moving the logic outside the Angular lifecycle.

1. Hot Config (JSON-based)

Instead of hardcoding values in environment files (which require a rebuild for every change), we use a standalone app-config.json. This allows for "Hot Configuration"—changing API endpoints or feature flags on the fly.

{
  "appName": "RAG System",
  "apiUrl": "http://localhost:3000/api/v1",
  "logMode": "console",
  "siteMode": "development",
  "version": "1.0.0",
  "langs": [
    { "code": "en", "label": "ENG", "description": "English", "langEn": "English" },
    { "code": "it", "label": "ITA", "description": "Italiano", "langEn": "Italian" }
  ],
  "google": {
    "clientId": "YOUR_GOOGLE_CLIENT_ID",
    "redirectUri": "http://localhost:4200",
    "button": { "theme": "outline", "size": "large", "text": "continue_with", "logo_alignment": "left" },
    "analyticsId": "YOUR_GOOGLE_ANALYTICS_ID"
  },
  "uploadConfig": { "maxFileSize": 50, "maxFileCount": 5,    "acceptedFileTypes": "*/*", "allowMultiple": false },
  "menus": {
    "guest": [
      { "label": "home", "icon": "home", "route": "/home" },
      { "label": "Login", "icon": "login", "route": "/login" }
    ],
    "user": [
      { "label": "Home", "icon": "home", "route": "/welcome" }
    ],
    "admin": [
      { "label": "_DIVIDER_" },
      { "label": "Config", "icon": "settings", "route": "/admin-config" }
    ]
  }
}

Enter fullscreen mode Exit fullscreen mode

2. The Static StoreService

To avoid the "chicken and egg" problem of Dependency Injection, I use a Static StoreService. By using static properties, the configuration becomes a global "Source of Truth" that is available even before Angular's DI container is fully ready.

import { Injectable } from "@angular/core";
import { BehaviorSubject } from "rxjs";
import { I18nLang, IAppConfig, IMenuItem, SiteModeType } from "../../Models/config.model";

/**
 * The app configuration is loaded when angular starts in main.ts
 * This service provides access to the configuration
 * and manages application state in localStorage/sessionStorage
 */
@Injectable({
  providedIn: "root",
})
export abstract class StoreService {
  private static PREFIX: string = "MYAPP
  private static CONFIG = {} as IAppConfig;

  // Token change observable
  private static tokenSubject = new BehaviorSubject<string>("");
  public static tokenChange$ = StoreService.tokenSubject.asObservable();

  constructor() {}

  static set(key: string, value: any, persistent: boolean = false) {
    key = this.PREFIX + key;
    if (persistent) localStorage.setItem(key, value);
    else sessionStorage.setItem(key, value);
  }

  static get(key: string): any {
    key = this.PREFIX + key;
    return localStorage.getItem(key)
      ? localStorage.getItem(key)
      : sessionStorage.getItem(key);
  }

  static remove(key: string) {
    key = this.PREFIX + key;
    localStorage.removeItem(key);
    sessionStorage.removeItem(key);
  }
  static clear() {
    localStorage.clear();
    sessionStorage.clear();
  }

  static getAll() {
    const allItems = { ...localStorage, ...sessionStorage };
    const filteredItems: { [key: string]: string } = {};
    for (const key in allItems) {
      if (key.startsWith(this.PREFIX)) {
        filteredItems[key.replace(this.PREFIX, "")] = allItems[key];
      }
    }
    return filteredItems;
  }

  // TOKENS SECTION
  static setJwtToken(token: string) {
    if (!token) {
      this.remove("auth_token");
      this.tokenSubject.next(""); // Emit empty token (logout)
      return;
    }
    this.set("auth_token", token);
    this.tokenSubject.next(token); // Emit new token (login/refresh)
  }

  static getJwtToken(): string {
    return this.get("auth_token") || "";
  }

  // CONFIG SECTION
  static setConfig(config: IAppConfig) {
    this.CONFIG = config;
  }

  private static getConfig(): IAppConfig {
    return this.CONFIG;
  }

  static getApiUrl(): string {
    return this.getConfig().apiUrl;
  }
// add static functions or properties for every options you need
}

Enter fullscreen mode Exit fullscreen mode

3. The "Pre-Flight" Boot in main.ts

This is the "smart" part. Instead of letting Angular start and then asking for the config, we fetch the config in main.ts and then trigger the bootstrap process.

import { provideZoneChangeDetection } from "@angular/core";
/// <reference types="@angular/localize" />
import { bootstrapApplication } from "@angular/platform-browser";
import { appConfig } from "./app/app.config";
import { AppComponent } from "./app/app.component";
import { StoreService } from "./app/Core/services/store.service";

// Load configuration before bootstrapping the application
const loadConfig = async () => {
  try {
    const dt = new Date().getMilliseconds();
    const response = await fetch(`./assets/config/app-config.json?ver=${dt}`);

    if (!response.ok) {
      throw new Error(
        `Failed to load configuration: ${response.status} ${response.statusText}`,
      );
    }

    const config = await response.json();
    StoreService.setConfig(config);

    // Bootstrap application after config is loaded (or failed to load)
    bootstrapApplication(AppComponent, {
      ...appConfig,
      providers: [provideZoneChangeDetection(), ...appConfig.providers],
    }).catch((err) => console.error("Application bootstrap failed:", err));
  } catch (error) {
    console.error(
      "Fatal error: Application cannot start without configuration:",
      error,
    );
  }
};
// Execute the configuration loading
loadConfig();
Enter fullscreen mode Exit fullscreen mode

Why is this better?
Zero Flickering: No "loading" spinners while waiting for settings.
No Undefined Errors: You don't need to check if(config) in every constructor.
Environment Agnostic: The same build works in Dev, Staging, and Prod just by swapping the JSON file.

Conclusion:
Bootstrapping shouldn't be an afterthought. By moving your configuration logic to the very edge of your application's entry point, you create a more robust, flexible, and predictable system.

What you think about this approach? Write your comments.

This is my first post so be kind. :)

References:
My RAD Framework
Source Code on Github

Top comments (0)