+1 (726) 227-3745

Fixing a Slow MEAN Front End: Angular 22 Deferrable Views, Bundle Budgets and Core Web Vitals

Most MEAN performance work we are called into starts on the server: slow aggregations, missing indexes, a chatty API. We have written about that side of the stack before. But once the API answers in 40ms and the page still feels slow, the problem has moved to the browser, and the fix looks completely different.

This tutorial walks through the front-end half of a MEAN performance engagement on Angular 22: measure first, cut the initial bundle, fix Largest Contentful Paint and Interaction to Next Paint, then stop the regressions with budgets in CI. Versions are current as of the Angular 22 line running against an Express 5 API.

1. Measure before you touch anything

Build in production mode and look at what actually ships:

ng build --configuration production
npx source-map-explorer dist/app/browser/*.js

The Angular CLI also prints an esbuild summary. The numbers that matter are the initial chunk total (everything the browser must download before the app boots) and the largest single dependency. In a typical client codebase we find three culprits: a charting library imported eagerly for a dashboard nobody lands on, a date library pulled in by one pipe, and every route bundled into main.js because the router uses component: instead of loadComponent:.

For field data rather than lab data, record real Core Web Vitals from actual users:

npm install web-vitals
// src/app/core/vitals.ts
import { onLCP, onINP, onCLS, type Metric } from 'web-vitals';

function send(metric: Metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    path: location.pathname,
  });
  navigator.sendBeacon('/api/vitals', body);
}

export function trackVitals() {
  onLCP(send);
  onINP(send);
  onCLS(send);
}

On the Express 5 side, a two-line endpoint is enough to start collecting:

app.post('/api/vitals', express.json({ type: '*/*' }), async (req, res) => {
  await Vital.create({ ...req.body, ua: req.get('user-agent'), at: new Date() });
  res.sendStatus(204);
});

Now you can answer "is it faster?" with a percentile instead of an opinion. Aim for LCP under 2.5s, INP under 200ms, CLS under 0.1 at p75.

2. Lazy-load every route

Standalone components make this cheap. Replace eager route components:

// BEFORE
export const routes: Routes = [
  { path: 'dashboard', component: DashboardComponent },
  { path: 'reports', component: ReportsComponent },
];

// AFTER
export const routes: Routes = [
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
  },
  {
    path: 'reports',
    loadChildren: () => import('./reports/reports.routes').then(m => m.REPORTS_ROUTES),
  },
];

Then give the router a preloading strategy so lazy chunks are fetched during idle time rather than at click time:

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes, withPreloading(PreloadAllModules)),
  ],
});

If the app is large, swap PreloadAllModules for a custom strategy that only preloads routes marked data: { preload: true } — preloading everything on a slow connection just moves the cost around.

3. Defer the heavy widgets with @defer

Deferrable views are the highest-leverage tool in the modern template syntax. Anything below the fold, behind a tab, or driven by a heavy third-party library belongs in a @defer block:

@defer (on viewport; prefetch on idle) {
  <app-revenue-chart [points]="points()" />
} @placeholder (minimum 300ms) {
  <div class="chart-skeleton" aria-hidden="true"></div>
} @loading (after 100ms; minimum 300ms) {
  <app-spinner />
} @error {
  <p>The chart could not be loaded. <button (click)="retry()">Retry</button></p>
}

The compiler moves RevenueChartComponent — and the charting library it imports — into its own chunk that is only fetched when the placeholder scrolls into view. Triggers worth knowing:

  • on viewport — below-the-fold content.
  • on interaction / on hover — tabs, accordions, comment threads, rich editors.
  • on timer(2s) — chat widgets and other non-essential third parties.
  • when condition() — signal-driven, e.g. admin-only panels.
  • prefetch on idle — download early, render late. This is usually what you want.

Two rules that trip teams up: the deferred component must be standalone and used only inside the defer block (if it is also referenced eagerly elsewhere in the template, the chunk merges back into the parent), and the @placeholder must reserve the final height, or you trade a bundle win for a CLS regression.

4. Fix LCP: images, fonts and the critical request chain

The LCP element on most dashboards and marketing pages is an image or a headline. For images, use NgOptimizedImage:

<img ngSrc="/media/hero.avif" width="1200" height="630" priority alt="Fleet overview" />

priority emits fetchpriority="high" and a preload link; width/height prevent layout shift; non-priority images below the fold are lazy-loaded automatically. Serve AVIF/WebP from your CDN and make sure the Express 5 layer sets long-lived immutable caching on hashed assets:

app.use('/assets', express.static('dist/app/browser/assets', {
  immutable: true,
  maxAge: '1y',
}));

For fonts, self-host, preload the single weight used above the fold, and set font-display: swap. Then kill the critical request chain: if your app boots, then calls /api/me, then calls /api/dashboard before painting anything, the LCP is the sum of three round trips. Use a route resolver or Angular's resource()/httpResource to start fetches in parallel with bootstrap, and render a skeleton immediately rather than a blank shell.

If the page is public and content-heavy, server-side rendering with incremental hydration is the bigger lever; we cover that setup separately.

5. Fix INP: zoneless, signals and OnPush

Interaction to Next Paint punishes long tasks on the main thread. Three changes do most of the work.

Go zoneless. Angular 22 apps can drop Zone.js entirely, which removes a large chunk of per-interaction change-detection churn:

bootstrapApplication(AppComponent, {
  providers: [provideZonelessChangeDetection()],
});

Remove zone.js from the polyfills array in angular.json afterwards. Zoneless requires that state changes go through signals, AsyncPipe, or an explicit markForCheck() — anything mutating a plain field from a setTimeout callback will silently stop updating, so migrate component state to signals first and run your test suite.

Stop recomputing in templates. Method calls in bindings run on every check. Replace them with computed():

// BEFORE: filterTasks() runs on every change detection pass
// <li *ngFor="let t of filterTasks()">

// AFTER
readonly query = signal('');
readonly tasks = signal<Task[]>([]);
readonly visibleTasks = computed(() => {
  const q = this.query().toLowerCase();
  return this.tasks().filter(t => t.title.toLowerCase().includes(q));
});
@for (task of visibleTasks(); track task._id) {
  <li>{{ task.title }}</li>
} @empty {
  <li class="muted">No tasks match.</li>
}

A correct track expression is not cosmetic: tracking by index on a re-sorted list makes Angular destroy and rebuild every DOM node.

Virtualise long lists. Past a few hundred rows, render a window with the CDK virtual scroller instead of the whole collection, and push filtering and pagination into MongoDB rather than the browser.

For the rare genuinely expensive computation — CSV parsing, geometry, crypto — move it off the main thread into a web worker (ng generate web-worker).

6. Lock the wins in with budgets and CI

Performance work decays within two sprints unless the build fails when it regresses. Set budgets in angular.json:

"budgets": [
  { "type": "initial", "maximumWarning": "350kb", "maximumError": "450kb" },
  { "type": "anyComponentStyle", "maximumWarning": "4kb", "maximumError": "8kb" }
]

Set the error threshold slightly above today's real number, not at some aspirational value — a budget that is already red teaches everyone to ignore it. Then add a Lighthouse gate to the pipeline:

# .github/workflows/perf.yml
name: perf
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24 }
      - run: npm ci
      - run: npm run build -- --configuration production
      - run: npx @lhci/cli autorun --collect.staticDistDir=dist/app/browser --assert.preset=lighthouse:recommended

Lab scores are a smoke alarm, not the truth — keep the field web-vitals data as your source of truth and use Lighthouse CI to catch the obvious regressions before merge.

A realistic order of work

  1. Capture a baseline: bundle report plus one week of field LCP/INP at p75.
  2. Lazy-load routes and defer below-the-fold widgets — usually the largest single win.
  3. Fix the LCP element: image format, priority, fonts, parallel data fetches.
  4. Go zoneless and move component state to signals to bring INP down.
  5. Add budgets and a Lighthouse gate so the numbers hold.

On a typical client dashboard this sequence takes a couple of weeks and moves the initial bundle from well over a megabyte to under 400kb, with p75 INP dropping below the 200ms threshold. The API work you already did finally becomes visible to users.

If your MEAN app is slow and you are not sure whether the problem is the query plan, the API, or the browser, our team does fixed-scope performance audits that measure all three and hand back a prioritised plan. Get in touch and tell us what your users are complaining about.