Can We Slash Time-to-Interactive from 8s to 1.2s in a Legacy Angular App Without a Rewrite?
Discover how precise code splitting and vendor chunk optimization reduced a legacy Angular monolith's Time-to-Interactive by 85%, unblocking the main thread without a full rewrite.


In early 2026, our team faced a harsh reality check regarding our primary customer dashboard, a monolithic Angular application built four years prior. The feature set had grown organically, turning what was once a nimble single-page application into a lumbering giant. Real-world user data, specifically from our cohorts in Southeast Asia with unstable 4G connections, showed an average Time-to-Interactive (TTI) of 8.2 seconds.
This wasn't just a metric; it was a conversion killer. Users were abandoning the login flow before the main thread even finished parsing the JavaScript. The initial hypothesis from management was to demand a complete rewrite in a newer framework. As the Principal Architect, I rejected that. The business risk of a rewrite was too high, and the velocity loss would be unacceptable. Instead, we needed to treat the symptoms of obesity. We needed to reduce the payload and, more importantly, unblock the main thread by surgically removing code that wasn't immediately required.
The Autopsy of a Bloated Vendor Bundle
The first step was not optimization, but forensics. We needed to understand exactly what was occupying those critical 8 seconds. We integrated webpack-bundle-analyzer into our build pipeline, but looking at the treemap was a sobering experience. The vendor.js file, containing all our third-party dependencies, had swollen to 2.4MB (minified and gzipped).
The main culprit was a combination of legacy UI libraries and, surprisingly, data visualization libraries that were being loaded on the splash screen despite only being used on the "Reports" tab, which only 20% of users visited. Furthermore, the polyfills.ts file was delivering compatibility for IE11 to all users, regardless of their browser.
This massive bundle was forcing the browser to spend nearly 4 seconds just parsing and compiling JavaScript before it could execute a single line of our application logic. The main thread was completely blocked. Any user interaction during this phase was queued, resulting in the perception that the app was frozen.
We also identified a critical anti-pattern in our module architecture. The AppModule was importing every single feature module—AuthModule, BillingModule, ReportingModule, AdminModule—eagerly. This meant that even a user who just wanted to check their invoice was downloading the code for the admin dashboard.
Establishing a Rollback Protocol Before Refactoring
Before touching a single line of routing logic, I enforced a strict disaster recovery protocol. Changing bundle splitting strategies is risky; a misconfigured lazy load can result in a white screen of death for users if the chunk fails to fetch or the server routes are misaligned. We needed a safety net that adhered to the principle of least privilege for our deployment pipeline.
We established a feature flag system at the build level. The new code-splitting architecture lived behind a configuration toggle. If the deployed bundle caused critical errors—specifically ChunkLoadError exceptions—we could revert to the monolithic bundle instantly without redeploying the application code, simply by flipping an environment variable in our CDN controller.
To support this, we utilized blue-green deployments on AWS using Route53 and EC2. This allowed us to route 5% of internal traffic to the new architecture. We monitored error rates and TTI metrics rigorously for 48 hours. This strategy ensured that if the code splitting introduced regressions, our rollback strategy was a DNS change away, not a frantic hotfix. This governance is non-negotiable when messing with the core loading mechanism of a legacy app.

Implementing Granular Route Lazy Loading
With the safety net in place, we began the actual refactoring. The lowest hanging fruit was Angular’s native support for lazy loading. We moved from eager imports in AppModule to dynamic imports in the Router configuration.
The transformation was conceptually simple but labor-intensive due to the size of the codebase. We changed routes from loading modules to loading promises.
// Before
{ path: 'admin', component: AdminComponent }
// After
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
}
We applied this to the AuthModule, BillingModule, and SettingsModule. The DashboardModule, which is the landing page after login, remained eager. This immediate change cut the initial JavaScript payload by roughly 40%. The browser no longer had to parse the code for the admin panel just to let a user log in.
However, lazy loading introduces a trade-off: the navigation latency when moving between routes for the first time. To mitigate this, we implemented a custom preloading strategy. We didn't want to preload everything (which defeats the purpose), but we did want to prefetch the BillingModule a few seconds after the dashboard settled. We used the PreloadAllModules strategy initially for testing, but eventually settled on a custom QuicklinkStrategy that only preloaded modules linked to the current viewport, ensuring we weren't wasting data on mobile users.
Surgical Precision with Webpack SplitChunks
Lazy loading the routes solved the application code issue, but our vendor bundle was still a monolith. Every lazy-loaded chunk was duplicating common dependencies because the default Webpack configuration didn't optimally split the node_modules.
We needed to instruct Webpack on how to group vendors. We updated the webpack.config.js to use a more aggressive splitChunks cache strategy. We identified that RxJS and Angular Core were used by almost every module, while D3.js and Moment.js were specific to the reporting module.
We configured cacheGroups to separate these. One group was framework-vendors (Angular, RxJS, Zone.js), and another was charting-vendors (D3, Chart.js). This ensured that the shared framework code was cached by the browser on the first load and reused by subsequent lazy-loaded chunks, preventing the user from downloading Angular five times.
One specific challenge we faced was the authentication library. We were using a heavy SDK to handle JWTs. Since authentication is required for almost every protected route, splitting this off into its own chunk often resulted in a waterfall of requests: Main -> Auth SDK -> Feature Module. We decided to keep the authentication core in the initial bundle but lazy-loaded the advanced profile management features.
This decision required careful coordination with our security team. If you are handling authentication flows, ensuring that the lazy-loaded auth modules are as secure as eagerly loaded ones is vital. We reviewed our implementation against secure OAuth 2.0 authorization code flow with PKCE principles to ensure that the lazy loading didn't introduce timing-based vulnerabilities where tokens might be exposed before the security context was fully initialized.
Why the Main Thread Still Matters in 2026
After implementing route lazy loading and optimizing the vendor chunks, our TTI dropped to 1.2 seconds. The 85% improvement was significant, but the technical debt remains.
We essentially put the legacy application on a severe diet. We did not fix the underlying architectural issue that the application is a tightly coupled monolith. We applied constraints (least privilege for code loading) to manage the bloat. As we look toward the rest of 2026, the team must remain vigilant. Every new feature added to the eagerly loaded DashboardModule adds to the TTI. We have instituted a "bundle budget" policy where any PR that increases the initial bundle size by more than 5KB requires explicit architectural review.
The takeaway is that you don't need a ground-up rewrite to solve performance crises. By understanding the critical path, isolating the heavy lifting, and ensuring you have a robust rollback strategy, you can resurrect performance in legacy systems. However, this is a finite lever. Eventually, the complexity of the business logic will outpace the benefits of code splitting, and we will have to confront the decision to migrate to a micro-frontend architecture. Until then, we have bought the user experience—and the business—two more years of stability.

