It all started in 2019. For me, it was time for a change, while for ING Bank Romania, it was time for an even bigger change. They were thinking about migrating the banking app from AngularJS to Web Components with Lit. I needed a new challenge, and that's how we started this journey.
For context, the banking app is the MVP of the company, one of the most feature-rich banking apps in the country. It has around 70 business flows and more than 200 screens. It's a single-page application used in the browser and the mobile app. Back then, the mobile app had a native wrapper for native functionality, like Cordova but built in-house, and the rest was just the SPA. The native wrapper was also the subject of a big update, from a simple wrapper to a more native-like experience with native screens, but more on that later since we're here to talk about the web app.
I had 80% of my time allocated to this migration. More precisely, I had to come up with a plan to migrate the app without any impact. The clients shouldn't feel any difference, at least not in a bad way. All the teams had to continue their usual activities, implementing new business requirements or maintaining the existing ones. So here's what we have: the most important app in the company, a tech stack that was new to the company and relatively new to the market, 100 employees who couldn't stop doing their jobs, and 1.5 million clients who shouldn't be affected. Well, yeah, no pressure.
Before I joined the company, there had been a few attempts at this migration, but nothing notable. First, we wanted to test a web component in the app. We saw what it involved, and it was nothing special - we chose a button as our web component. All good, but with just a few buttons, you can't migrate an app. That's the moment when I started to draw up the plan.
Two months after I joined the company, I started to see the bigger picture. I had time to browse the codebase, understand the infrastructure, the integrations with the native wrapper, and some quirks and hacks. The codebase was 5-6 years old and had a few partial migrations to different patterns from the AngularJS world. The inconsistency across the app didn't help since I had to understand even more concepts about the framework while having zero experience with AngularJS.
Let's jump into the plan. I defined my scope: a way to build new flows without using AngularJS, except for a wrapper to connect the flow to the app. The wrapper should be easy to drop and have nothing to do with the flow's codebase. I split the problem into smaller pieces. The app has a core of shared functionality and the actual business flows. The flows consist of core functionality, flow-specific logic, and UI components.
I made a list of requirements:
- A method to connect a new flow to the app;
- A router - the flow should have multiple screens;
- A way to make HTTP requests to the bank's API;
- A place to build new UI components;
Finally, I had my plan in 3 phases:
- Phase 1 : unlock the possibility to build new flows using web components - the most complex and risky phase;
- Phase 2 : Rebuild the existing flows - the longest phase;
- Phase 3 : Remove the AngularJS framework from the codebase - the real party;
Phase 1
UI Components
I started with UI components. It was a good opportunity to play more with the new stack. I created a monorepo library inspired by the Open Web Components recommendations. I changed a few things, and we got a pretty good way to build UI components. I was happy with the DX, knowing that it would play a key role in onboarding the teams to contribute.
The flow
It was time to crack the most complex issue. The first part was easy. I created a simple AngularJS flow as a wrapper that would render a single web component. The web component represented the actual business flow. No issues here since AngularJS works with plain HTML, and web components are just that.
Router
From here, things get tricky. The next stop was the router. I needed a way to navigate between the screens of a flow. But there was a problem. The app already had a router. Being an AngularJS app, it used ui-router. That helped because it offered a lot of options when it came to customization.
With a router already at the root level, the only option was to have another web components-based router embedded in the flows. I found a router built by my colleagues from another country that looked promising. It had different options for keeping state, including URL fragments (hashtags), which was the solution used by ui-router. I changed a few things because it was designed to work as a single instance at the root level.
All good so far, but there was one last thing to fix. Now, both routers were listening for URL changes, so I had to find a way to keep ui-router listening only on the root of the flow, and from there, the web components router would take control. Luckily, ui-router has an option for this by using a custom URL service provider, and that's what I did. Finally, I had a dummy flow with multiple screens connected to the app.
HTTP requests
Everything was going according to plan. Now it was time to add some real functionality to my dummy flow. One of the most common things we do is consume data from the API. I thought it should be simple. I mean, how hard could it be to make a simple HTTP request? Well, that's the moment when Pandora's box opened.
I needed to add the authentication token to every API call. I looked at the current implementation, which used the $http module with a custom interceptor, a 700-line interceptor. That didn't look good. I ignored all the rules; all I wanted was a successful request. I tracked down the token's location, and soon, I ended up with around 20 connected AngularJS services used across the entire app.
I tried to deangularize them one by one while keeping the original ones in place. It didn't work. Many of them had internal state, and you would end up with stale state when you switched between AngularJS and web components flows. I knew that I had identified the real core of the app and decided to deangularize these services and remove the original ones. There was no way back; the impact was huge. Every piece of code had to work with my vanilla services. There were a lot of trial-and-error sessions and a lot of research into AngularJS concepts.
After a few intense weeks, I saw the light. I had the minimum core deangularized, and I could make my HTTP request.
The first deploy
I was ready to deploy my first version capable of running web components flows. At least, I thought I was. To deploy the app, it needed to pass a few checks, including unit tests. We had around 6,000 unit tests back then. I tried to run them, and the task runner could run only a hundred (not pass, just run). I spent another session changing a few hundred files, coming up with all kinds of solutions for mocking services and other fixes. After I fixed everything, I was pretty confident that I had reduced the risk of breaking the app. I did my first deployment, and it worked, at least in the browser.
Before I prepared it for the QA teams, I gave it a shot in the native wrapper. Nothing worked. Oh, did I mention that while I was making massive changes to the web app, the native team was doing the same on their side? Under the pressure of deadlines, they made some risky decisions and started to "consume" AngularJS services in the native wrapper to bypass different steps during login. That hit hard because I had just successfully deleted all those services. I went back to debugging and browsing the native code, searching for injected JavaScript. I made a list of all the services they consumed and re-registered them using the deangularized versions. It worked!
Testing
Now I was ready to get the green light from the QA teams. I tried to push to get it prioritized and emphasized the importance of my changes. I had to wait two more months for a slot. Other things were already planned, and we couldn't take the risk. Finally, the testing started, and the bugs started to pop up. The first bugs were about AngularJS's digest cycle or simple typos. Easy to fix. Between bug fixes, I asked my manager to find a good candidate for the first flow written with web components. I built it, and I was waiting for the big moment.
Launching Phase 1
After almost ten months, maybe October 2020, we were ready for production. We did the release without any major incidents to report. More than 600 files were touched, and the clients didn't notice. It was a big success! The plan worked, and we unlocked Phase 2.
Phase 2
We had all the requirements to rebuild the app using just web components. I wrote documentation and did a lot of sessions to onboard my colleagues on this journey. We had a recipe for every scenario, the core was already deangularized, a real flow to use as a reference, and a lot of AngularJS code snippets translated into vanilla JavaScript.
While I handled the first and third phases by myself, the second one involved everyone. Every team owned a few flows. The teams were full-stack, made up of frontend and backend developers, QA, and a project manager. They had to migrate their flows according to their own schedules and priorities. The migration was a top-level priority from a security perspective because, on January 1, 2022, AngularJS had officially reached its end of support. But in reality, once Phase 1 was complete, with new flows built only with the new tech stack and some existing flows already migrated, the priority started to fade away.
The business people had other KPIs, other plans. If they touched a flow, they also wanted to redesign it and improve its UX. The migration from AngularJS to Web Components wasn't bringing any value to the table for clients - same flow, same experience, but different technology - it makes sense. I tried all kinds of approaches to prioritise the migration, but without any luck. Things were clear - we weren't simply migrating from AngularJS to Web Components, we were rebuilding the app flow by flow from scratch.
Year after year, I was tracking the progress and making all kinds of internal migrations that would reduce the footprint of AngularJS. We even had some scripts that counted different AngularJS signals, like how many $injects, controllers, directives, and so on we had. By 2023, we were deep into the migration, and so was the native app, but we were nowhere near finishing it.
Persistent navigations
The marketing team was pushing for better tools to sell the products through push notifications, inbox messages, banners, and landing pages. All these channels had one thing in common: navigation. All of them would end up navigating to a flow, and that was another challenge for me. At that point, all these navigations were handled by ui-router through its own states. I started to analyze and track all the external navigations - it didn't look good. We had ui-router states persisted in external platforms. Besides the usual bugs, edge cases, and missing documentation, my migration plan was blocked - I couldn't delete AngularJS if we had ui-router states spread across multiple platforms. I switched the external navigations from ui-router states to persistent IDs. Every page that could be opened through an external channel would get a unique ID. I also centralized all these pages with their IDs and input parameters to make it clear to everyone what needed to be sent to open a specific flow. The ID and the input parameters became the public API of the flow. After a few months of searching, the AngularJS footprint was eliminated from the external platforms.
By 2024, we were left with a bunch of flows to migrate. The migration was getting closer and closer to being finished, but migrating these last flows felt like an eternity. We were already 4 years into Phase 2. The initial plan was sequential: the next phase would start when the current one was done. In this case, I would have had to wait for these last flows without making any decent progress. Then I changed my mind about the plan: what if I could start Phase 3 before Phase 2 was done?
Phase 3
Without anything major on my plate, in the summer of 2024, I started to draw up the updated plan. If I had waited for the second phase to be finished, I would no longer have any AngularJS flows, just ui-router controlling the wrapper flows - which render the root web component for each flow. In that case, I would need to change the main router from ui-router to a web components-based router and then remove AngularJS completely. It sounds simple, but I would still need to extract the embedded web components router, make it the main router, adapt the native app integration that involved a hook, and do many other things. And only after that could I remove AngularJS.
I wanted to make some progress while I was waiting for the final flows to be migrated. I would have had to make all the above changes + keep AngularJS and ui-router in place. And for that to happen, I would have to invest time in a temporary solution that could be gone in a few months. I weighed the pros and cons, and I decided to move forward. I extracted the embedded web components router from the flows, reverted some of my changes from 2020 that had made it embeddable, and instantiated it at the root level. I removed all the AngularJS wrapper flows from the Web Components flows, and now they were connected directly to the main router. I had two things left to do: make ui-router work and keep the integration with the native app in terms of navigation, which was built on top of a ui-router hook.
Two main routers
Initially, I was thinking of using the same approach I used in Phase 1. I tried to render the AngularJS flows inside a single Web Component. It didn't work since ui-router had the same issues when it was removed and later reattached to the DOM. After a few more combinations, I found the winner.
I registered the AngularJS routes in the Web Components router, which would render a dummy web component used as a switch. The ui-router remained at the top level, side by side with the Web Components router, but it was configured to render nothing when an unknown route was opened. The ui-router would be displayed or hidden by the dummy web component dedicated to AngularJS routes. After a few more adjustments to make $stateParams work for navigations from Web Components to AngularJS, it was done. Then I made a few more changes to the web components router to support a hook for the native integration. In May 2025, after 11 months, 1,000+ files touched, and a YOLO code review, the PR was merged. We had AngularJS isolated and a clear path to remove it.
It took us another 10 months to migrate the last flow, and that proved to me that I'd made the right call to start Phase 3 earlier. When the final flow was gone, I dropped the final PR called "The Red Wedding". It was beautiful. Nothing but red lines. After more than 6 years, my plan was complete. The banking app was AngularJS-free, without any impact on clients.
Learnings
It was the biggest challenge in my career so far. It was both complex and a test of endurance. Keeping a plan on track for more than 6 years isn't easy. You have a lot of ups and downs, a lot of frustrations, and a lot of changes you have to adapt to. Over all these years, the app evolved, priorities changed, people changed, and parallel migrations happened.
One thing helped me the most in this task: the fact that AngularJS works with simple HTML and web components are exactly that. Also, Lit is great. That helped a lot too.
From this migration, I learned that you always need to see the bigger picture. You always need to understand the pieces on the table to visualize the final solution. I did all this stuff while having zero experience with AngularJS. In most frameworks, and in software in general, there are a lot of abstractions and conventions that block your view. You need to learn how to unfold these layers to see how things really work.
It was a fun ride. Farewell, AngularJS!