Why We Moved from Angular’s Static AOT i18n to Runtime-Loaded Translations

· Medium ·

8 min read Original article ↗

Tomáš Režnar

When you build enterprise software at scale, performance problems rarely show up where you expect them.

I’m a Principal Engineer at Operations1 (cioplenu GmbH), where we build a SaaS application used by large industrial customers to run and document their operations.

This is a story about how a seemingly “best practice” setup with Angular’s built‑in i18n and AOT compilation turned into a serious network bottleneck for one of our enterprise customers — and how moving to runtime-loaded translations substantially reduced our deployment traffic footprint.

The Alert: “Your Release Broke Our Network”

One day, one of our enterprise customers reached out with a worrying report: their infrastructure team had detected noticeable performance issues on the corporate network whenever we released a new version of our app.

Our monitoring showed a distinct spike in download traffic during those deployment windows. The spike reached roughly 22 MB/s and lasted for about an hour, which was still quite visible in parts of the customer’s infrastructure — especially Wi‑Fi segments with many shared devices.

Press enter or click to view image in full size

Network I/O during release before optimization

This customer runs many devices with our Operations1 application, often in factory environments. When all of them start updating around the same time, it’s very easy to stress the network.

At that point, we were releasing every two weeks. Even at that cadence, the impact was already painful for them. And we wanted the flexibility to release more frequently in the future if needed. Clearly, this was not sustainable.

Something in our architecture had to change.

Our Setup: Angular, AOT i18n, 12 Languages, and Service Workers

Operations1 is a single-page application built with Angular.

We use Angular’s built-in i18n to localize the UI into 12 languages. The “classic” Angular way to do this is:

  • Use the @angular/localize tooling and i18n markers in templates.
  • Let the build pipeline produce one separate build per language.
  • Deploy those language-specific builds as independent bundles.

This approach has some strong advantages:

  • Templates are translated at compile time, not at runtime, which is great for performance.
  • Each language bundle contains only the translations it needs, which is good for code size per locale.

On top of that, we used the Angular Service Worker to make Operations1 a Progressive Web App.

Because our customers often work in factories with intermittent or poor connectivity, we configured the service worker to prefetch most application assets. The idea was:

A device might be shared between multiple workers on different shifts, speaking different languages.

We want them to be able to switch the app language even on bad connections — and still work smoothly.

To achieve that, we made the service worker eagerly cache assets for all 12 languages.

This ensured great offline support and instant language switching, but it also set us up for trouble.

Where Things Went Wrong

Taken individually, each decision made sense:

  • Multiple localized builds per Angular’s recommendation for i18n.
  • Service worker prefetch to guarantee offline availability of all assets.

But in combination, and at the scale of our customer’s fleet, they became problematic:

  1. Many builds → many assets
    For 12 locales, the build generated many localized variants of the same application bundles. Since Operations1 is a feature-rich industrial app, each locale build was already substantial. Because our service worker cached every localized variant, a single device update could pull roughly 314 MB total (about 97 MB gzipped over the wire).
  2. Synchronization effect on customer devices
    The Angular service worker supports an installMode: "prefetch" mode for asset groups, which instructs it to download every listed resource as soon as the new version is installed.
    We used this aggressively to make sure every language and asset was available offline. Because we deployed on a fixed schedule, and devices checked for a new version on a fixed interval, many of them would discover and start prefetching this massive 314 MB (or 97 MB gzipped) aggregate payload within the same hour.

The result:

When we rolled out a new release, dozens or hundreds of devices behind the same corporate network segments simultaneously pulled a huge set of files — most of which they would never use, because a single device typically only needs one language at a time.

This understandably created friction for the customer’s network team.

Why This Was a Priority Problem

We could have tried to “schedule around” the issue (nightly updates, maintenance windows, and so on), but this would have fought against our product goals:

  • We already ship on a two-week release cycle and want the option to increase our release frequency when needed.
  • We want our customers to always get fixes and improvements quickly, without manual coordination.
  • We can’t assume all factories and sites are active at the same time, so the question was:

How can we keep frequent releases and offline support, without hammering our customers’ networks every time we ship?

The answer was to change how we handle translations and updates.

The Fix, Part A: One Build, Runtime-Loaded Translations

The first major change was to move from multiple static i18n builds to a single build with runtime-loaded translations.

We still rely on Angular’s native i18n syntax in our templates, but instead of baking translations into 12 separate AOT build outputs, we shifted to a runtime approach:

  • We produce one single application bundle (one build).
  • On application startup (before bootstrapping Angular), we fetch the appropriate translation file for the active language.
  • We then pass the translation map to loadTranslations from @angular/localize before Angular bootstraps. This is the official—though less commonly discussed—mechanism Angular provides to populate the $localize runtime translation maps.
import { loadTranslations } from '@angular/localize';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

async function bootstrapApp(): Promise<void> {
try {
const locale = determineActiveLocale();
const response = await fetch(`/assets/i18n/${locale}.json`);

if (response.ok) {
const translations: Record<string, string> = await response.json();
loadTranslations(translations);
} else {
console.warn(`Could not load translations for "${locale}", falling back to default language.`);
}
} catch (error) {
console.error('Translation loading failed, bootstrapping with default language.', error);
}

const { AppModule } = await import('./app/app.module');
await platformBrowserDynamic().bootstrapModule(AppModule);
}

  • When the user changes the language, we reload the app so it can start with the new locale’s translations.
  • Operators typically do not change language very often.

The impact on bundle size was dramatic:

Based on our approximate internal measurements, the total data downloaded by the service worker during an update dropped from roughly 314 MB (or 97 MB gzipped) — the aggregate of all 12 localized builds — down to about 54 MB (or 18 MB gzipped) — the single generic build plus the active language file.

That means each device now needs to download an order of magnitude less data when a new version is released.

The Fix, Part B: Staggered Update Checks

Even with a much smaller build, having hundreds or thousands of clients check for updates at the same minute can still cause avoidable peaks.

We implemented our own polling strategy using Angular’s SwUpdate service. Originally, we configured this to check for a new version on a fixed 60-minute interval.

This made client behavior too synchronized:

  • If many devices open the app around the same time, a large portion will hit the new version within the same hour, in a very tight window.

To spread the load more evenly, we changed the strategy:

  • Instead of a fixed 60‑minute interval, every client now randomizes its update check interval somewhere between 45 and 75 minutes.

This simple change is enough to spread update checks out over time:

  • Devices drift away from each other’s schedule.
  • Version checks and downloads become more uniformly distributed across the hour.
  • Network traffic spikes are smoothed out significantly.

The Result: From “Release Storm” to Gentle Ripples

After deploying the new setup (single build, runtime loadTranslations, randomized update checks), we monitored the same customer’s network behavior during our next release.

The difference was clearly visible in our monitoring:

  • The traffic spike after release shrank to less than 10 MB/s.
  • The elevated download period went from about 60 minutes down to roughly 15 minutes.

Press enter or click to view image in full size

Network I/O during release after optimization

For the customer, the upgrade process became much less disruptive.

For us, this opened the door to releasing more frequently without risking complaints from network operations.

Lessons Learned

  1. “Best practices” are context-dependent
    Angular’s AOT i18n with per-locale builds is a solid default for many web projects. But in a highly localized, offline-first, multi-device enterprise environment, it can backfire when combined with aggressive prefetching.
  2. Service worker configuration can be a hidden risk
    Using installMode: "prefetch" with large asset groups across multiple locales is extremely bandwidth-intensive.
    It’s great for offline support, but you must consider how many clients will execute this pattern simultaneously.
  3. Runtime-loaded translations are a powerful lever
    Using @angular/localize’s loadTranslations allowed us to move to a single build, reducing the total data fetched during a service worker update.
  4. Randomization is your friend
    Even simple randomization of custom SwUpdate polling intervals can prevent synchronized “thundering herd” behavior across thousands of clients and significantly flatten network load.

Closing Thoughts

This migration wasn’t about chasing a new library or framework trend.

It was about aligning our architecture with the operational reality of our largest customers.

If you operate a multilingual Angular SPA in environments with many devices and constrained networks, it’s worth asking:

  • How many bytes does each release actually push through your customers’ networks?
  • Are you shipping multiple localized builds where a single build plus runtime translations would suffice?
  • Are your update checks synchronized in a way that creates unnecessary peaks?

Sometimes, the easiest performance win is not a clever micro-optimization in code — but changing how and when your app moves its bytes across the wire.