Frontend performance for enterprise apps: budgets, not heroics
Frontend performance in enterprise apps: for real users, budgets that fail CI, , long tables, images and .

Enterprise apps rarely get slow in one release. They get slow one dependency, one widget and one "temporary" table at a time, and nobody owns the total. Then a branch employee on an old PC waits for the transactions screen, and the fix becomes a project.
Performance in a long-lived codebase is not a heroic sprint. It is a set of limits the build checks every day, and a way to see what real users feel.
The few concepts that matter
Core Web Vitals, measured on the slow users too. Core Web Vitals are three numbers for how a page feels. (Largest Contentful Paint): how fast the main content shows, good within 2.5 seconds. (Interaction to Next Paint): how fast the page reacts to a click, good at 200 milliseconds or less. (Cumulative Layout Shift): how much the page jumps while it loads, good at 0.1 or less. web.dev suggests measuring "the 75th percentile of page loads, segmented across mobile and desktop devices". The means: sort all page loads from fast to slow and take the one three quarters of the way down. If that one is good, three out of four users get a good page. An average would hide the slow quarter; this does not.
Image: tt-tech.io, adapted from web.dev, Web Vitals, CC BY 4.0.
INP for data-heavy screens. INP "assesses a page's overall responsiveness to user interactions". A back-office app loads once in the morning and is clicked all day: filters, sorting, opening a row. That is where enterprise users feel slowness, so watch INP first on those screens.
Budgets in the build. A is "a set of limits imposed on metrics that affect site performance". has them built in: a budgets section in the build configuration, with maximumWarning and maximumError per type. The initial type defaults to a warning at 500kb and an error at 1mb. A budget in a wiki erodes. A budget in the build is the same rule for every team.
Lazy loading per route and per widget. One lazy route per feature keeps each team's code out of the others' first load. Inside a screen, @defer blocks "reduce the initial bundle size" by deferring code "not strictly necessary for the initial rendering of a page". React does the same with lazy and a <Suspense> fallback.
Long lists and tables. The 's <cdk-virtual-scroll-viewport> displays long lists "by only rendering the items that fit on-screen". For a transactions list of ten thousand rows, ask first whether the server should paginate. fixes rendering; it does not fix downloading ten thousand rows.
Rendering cost. With and as the defaults in recent Angular versions, much of the old cost is gone. What stays is the list: track lets Angular run "the minimum necessary DOM operations when the data changes", and tracking by object reference "can lead to significantly slower rendering updates".
Images in image-heavy apps. When a screen shows many images, product photos, scanned documents, avatars, use Angular's image directive: <img ngSrc> instead of <img src>. NgOptimizedImage "makes it easy to adopt performance best practices for loading images": images not marked priority get loading=lazy by default, it "generates an appropriate srcset" from the sizes attribute, and it requires a width and height, which prevents layout shifts. Mark the main image of the page as priority, because it is usually the LCP.
Field data on real devices. comes from "a controlled environment with predefined device and network settings"; field data comes from real users, so it reflects "the actual devices, network conditions". Your laptop is not the branch PC or the older Android phone your customers use.
The laptop of the developer is the one device your users never have.
When to choose what
- Budget type: start with
initialandanyComponentStyle. Add per-bundle budgets when a lazy feature starts to grow. @deferor a lazy route: a lazy route when the code belongs to another screen;@deferfor a heavy widget on the same screen (a chart, an editor).- Virtual scroll or pagination: pagination on the server when users search and filter; virtual scroll when they really scan a long list on one page.
- Lab or field: in CI to catch regressions early, field data to decide what to fix.
On Atlas Bank
Atlas Bank loads every feature lazily. apps/atlas/src/app/app.routes.ts has one loadChildren per area, the dashboard, accounts, requests, and both legacy corners, so the screen only costs something when a user opens it.
The budgets in apps/atlas/project.json are the CLI defaults, kept on purpose:
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],The build uses the production configuration by default, and the repo's CI runs build on every pull request, so the budget is checked before a merge. Lists use @for with track t.id, for example the transactions table in libs/feature/accounts/src/lib/account-detail.ts.
What Atlas does not have yet: a long transactions list. That would be a small addition for a virtual scroll exercise; today the demo data has fifteen transactions.
What I left out, and why
- , , compression. The build does them.
- . A real tool, but in enterprise apps the heavy work belongs on the server more often than in a worker.
- , , . One line: use them when a measurement says a resource arrives late, not by default.
- Image formats and image .
NgOptimizedImageworks with an image loader when you have a CDN; picking formats is an operations choice.
A budget that fails the build protects the app from every team, including yours.
On Monday
- Open your
angular.jsonorproject.json. If there is nobudgetssection, or the error limit is far above today's size, set it just above the current initial bundle. - Pick the screen your users click most and look at its INP in field data, if you collect it. If you do not, that is the first ticket.
- Find the largest
@foror.map()in the app and check what it tracks by.
For your coding agent
The rules of this article, written for an agent. Paste them into your AGENTS.md, save them as a skill, or point your agent at the file.
# Frontend performance rules for enterprise apps Source: https://tt-tech.io/blog/frontend-performance-enterprise-budgets For Angular and React frontends. Add to AGENTS.md or save as a skill. Your project's own rules win where they differ. ## Rules 1. Keep a `budgets` section in the build configuration (`angular.json` or `project.json`) with `maximumWarning` and `maximumError`. Start with `initial` and `anyComponentStyle`. Add per-bundle budgets when a lazy feature starts to grow. 2. When there is no budget, or the error limit is far above today's size, set it just above the current initial bundle. 3. Load each feature through its own lazy route. Code that belongs to another screen goes in a lazy route; a heavy widget below the fold on the same screen (a chart, an editor) goes in `@defer`. In React, use `lazy` with a `<Suspense>` fallback. 4. Track every `@for` by a stable id (`track item.id`), never by object reference. Give React lists rendered with `.map()` a stable key in the same way. 5. For long lists, paginate on the server when users search and filter. Use virtual scroll (`<cdk-virtual-scroll-viewport>`) when they really scan a long list on one page. Virtual scroll fixes rendering, not downloading. 6. In image-heavy Angular screens, use `NgOptimizedImage` (`<img ngSrc>`, not `<img src>`) with a width and height, and `sizes` for the `srcset`. Mark the main image of the page `priority`. 7. Judge Core Web Vitals at the 75th percentile of page loads, mobile and desktop apart, never by average. Good is LCP within 2.5 seconds, INP at 200 milliseconds or less, CLS at 0.1 or less. 8. On data-heavy screens (filters, sorting, opening a row), watch INP first. 9. Use lab data (Lighthouse in CI) to catch regressions and field data from real users to decide what to fix. A run on a developer machine is not what users get. 10. Add `preload`, `prefetch` or `preconnect` only when a measurement shows a resource arrives late, not by default. ## Before you say done - Run the production build. It must pass the budgets. Report any new budget warning and the initial bundle size before and after. - Search the diff for `@for` blocks that track by object or `$index`, and for `.map()` lists without a stable key. - Search the diff for `<img src` in Angular templates, and for heavy libraries imported outside a lazy route or `@defer`.
Sources
- web.dev, Web Vitals
- web.dev, Interaction to Next Paint (INP)
- web.dev, Performance budgets 101
- Angular, Building Angular apps
- Angular, Deferred loading with @defer
- React, lazy
- Angular CDK, Scrolling
- Angular, Control flow
- Angular, Optimizing images with NgOptimizedImage
- web.dev, Why lab and field data can be different (and what to do about it)