Modernising our frontend, from monolith to micro-frontend

I am going to tell you about a long and winding journey we have been on over the past year. From the depths of winter through the times of Covid-19, we have been migrating our UI architecture from a monolithic frontend to a micro-frontend. There are several different approaches to building micro-frontends but we decided to use single-spa (S-SPA) as our underlying technology. We haven’t reached our micro-frontend destination yet but we are getting very close.
You might be wondering why we are taking this journey when our current architecture has worked pretty well for us over 4 years now. To be honest, we could stay with our current architecture, but we are starting to struggle to scale. Our platform, Ed, is used by millions of users each day and it is constantly being updated with new features. In the past, there would have been about 3–4 teams working regularly on the Ed UI, however this has grown to more than 8 teams simultaneously working on features for the platform. A year ago it would take about 40 minutes from the time a developer did a merge, to getting their code deployed to a pre-production environment. When you have 8+ teams, with multiple developers on each team, trying to get features to production every day, waiting 40 minutes before you can even merge your code, just is not sustainable.
In an ideal world we would have started from scratch and left our older Angular and React codebases behind, but it’s very hard to rebuild everything from the ground up, all while delivering new valuable features to customers (as my colleague Clíona de Róiste outlined here). While we transition to our new architecture, we have been gradually updating and rewriting our Angular/React apps to work with the S-SPA architecture. Over our next few minutes together I am going to explain how we have been doing this, what has gone well, what hasn’t gone quite as well, and what we still have to do in the final stretch.

In the beginning there was the monolith
Let me take you back to the end of 2019 when our Ed platform was a collection of Angular 1.x applications. The Angular apps had their own repo with a test, build, and deploy pipeline. The underlying technologies that Ed was using were quite old (I swear this was a modern tech stack once). The Angular UIs were built using Grunt, Bower, Require.js, and NPM modules. To test the Angular apps we were using Jasmine, Karma, and Protractor. Since we had been moving away from Angular for a number of years, our newer features were built in React and exposed in the Angular app using ng-react. At the time our React codebase was in a separate repo, published to an internal NPM repository and then consumed by the Angular application. The React repo also had its own build, publish, and test pipeline. For the React library we were using Webpack and NPM modules and to test we were using Jest. Since the majority of our new code was written in React, the deployment process became quite tedious and error prone. A developer would merge their PR in the React repo, kick off the job to publish the package to the NPM repository, and then when the package was published, the job to build and deploy the Angular application that used their new React code would start. Have you got a headache thinking about how all this worked? I sure have one.
Around the same time that we started to look at how we could migrate our existing apps to S-SPA, HMH decided to move towards a UI monorepo (As my colleague Aislinn Hayes has outlined in her article on migrating to a monorepo). We felt that the next logical step was to migrate our Angular and React projects into the monorepo and lean heavily on Lerna and Yarn workspaces rather than maintaining two heavily dependent repos and pipelines. As you probably know already, monorepos are not a panacea but it made sense for our particular use-case of two already heavily dependent repositories. We have had problems with our monorepo but compared to the problems we previously had, the benefits so far seem to outweigh the negatives.

Webpack all the way down
We were nearing our Yuletide vacation, so we spent the last week or two of 2019 migrating our two Angular and React repos into the monorepo. This was a monumental task and we still had a lot of work to do before we could even start moving our Angular apps into an S-SPA architecture. Our React code was being built with Webpack and we had decided on using it as well to build the container for our new S-SPA architecture. We needed to change how Ed was built so we obviously decided to move our Angular applications to Webpack as well. Moving to Webpack meant that we could stop using deprecated technologies like Bower, and we hoped it would help improve the speed of our CI/CD. We had found Grunt and Require.js to be quite slow building our application (our build increased to almost an hour a few years ago because of an older version of Require). Since we value the developer experience we also wanted to migrate our Angular unit testing framework to Jest so there would be less cognitive overhead switching between the Angular and React codebases. Looking back a year on, we took on far too much work for such a short space of time.
Before I go any further I have to be honest and say that migrating the entire build architecture of an application that is live in production and which is depended upon by millions of users, is not something you should take lightly. It’s even more difficult when there can be no change to how our users use and experience our platform (so no downtime). In HMH we live by Continuous Delivery and we wanted the switch of architecture to be delivered just like any other user visible feature. This meant that we would not be doing something like creating a long lived development branch and then merging back only when we had the entire architecture ported over (In HMH we use Scaled Trunk Based Development). To be honest, we would not have agreed to do this project if we had used long lived branches.
We treated the architecture change just like any other feature and we broke it down into smaller pieces that could be delivered incrementally. We looked at what dependencies we were fetching from Bower and for the vast majority of them we were able to replace with the equivalent package from NPM. For others, we were able to switch to a similar package from NPM and when there was nothing equivalent in NPM, we were able to fork the repo and host the dependency in our internal NPM repository.

We sometimes forget this, but Ed from our users perspective is one single application. Unknown to our users it’s actually several small Angular applications for login, LTI, forgot-password, registration, new password, and the main application that users interact with after they login. Some of these apps were quite small and we decided to focus on them first. For the smaller applications, we decided that we would create a Webpack config that would be used to build the app’s JS bundle alongside the Require.js version. Both the Webpack and Require.js versions of the app were then going to be deployed to production but only internal users would be able to access the new Webpack version. Our end-to-end testing suites would then run on both versions and when we were certain that the Webpack version was working correctly, we would switch non-internal users to the Webpack version. We decided we’d repeat this process for each of the smaller apps and then when they were done we do it one last time for the main Angular application.
The biggest issue we came across were Require.js loader plugins. Require.js loader plugins allow you to load files other than Javascript into your application (eg. text for HTML files). The syntax for Require plugins is <PLUGIN_NAME>!<PATH_TO_RESOURCE>
while the syntax we wanted to use was the standard Javascript static import syntax. Since we weren’t using long lived branches, we decide that the files where we used the Require syntax could be duplicated and the imports updated to be standard Javascript. When we did the switch over for non-internal users we could delete the file with the old import syntax. We encountered other issues (Eg. files loaded at runtime by Require.js or Angular) but the majority of these issues we were able to solve by using an existing Webpack plugin or writing our own plugins.
The gradual switch over to Webpack took about a month until all that remained was our main (and largest application) Angular app. This app was the one we were most worried about but the work we did on the other apps made the transition go very smoothly. It was time we celebrated with a refreshing beverage in HMH’s local pub.

Results beyond our wildest dreams
By the end of January 2020 we had all of our apps ported over and we began to look at the results of our migration (Unknown to us we had even more reasons to celebrate). Prior to the move to Webpack, the time it took a developer to get code built and deployed to any environment fell from around 30–40 minutes down to around 14–20 minutes. Sitespeed is one of many tools that we use in HMH to monitor performance. With Sitespeed we were able measure the performance of users logging into Ed with simulated 3G speeds. We were particularly interested in these metrics as we have many users who have slow or unreliable internet connections and we wanted to know what they experienced when using Ed. Before the architecture change, users with 3G speed spent an unacceptable amount of time logging in and waiting for the Ed landing page to load. After we moved to Webpack, this time fell by approximately 60%. These results were fantastic and to be honest we couldn’t believe they were true. We had hoped we would see some small performance improvements but a double digit improvement was beyond our wildest dreams.
So we have reached February and unbeknownst to us we had stormy seas ahead. The next step for us was to take our new shiny Webpackified apps and load them as S-SPA apps. This was the last step we needed before we could move to our UI away from the monolith pattern. We eagerly got to work on moving our apps into our new S-SPA architecture when Covid-19 appeared on the horizon. We had planned to have everything moved from the monolith by July 2020, but almost over-night every student in the US was stuck at home and they needed Ed to be able to do their school work. As much as we wanted to move to using S-SPA, it was far more important that we did all we could to make sure students were still able to continue their education as easily as possible.

The final piece of the puzzle
We didn’t abandon the migration, we just put it on hold because of the pandemic. Around April 2020 I was one of only a few developers still actively working on the migration. We had identified 2 pages inside of our main Angular application that could be broken from the monolith and loaded as two S-SPA apps. Even though the July migration date was no longer going to happen, we decided to still break the apps out, to document the steps while it was still fresh in our mind.
So you are probably asking how do you even break down a monolithic Angular 1.x app into a smaller app? It was a lot easier than you would think. We knew the pages from Ed we wanted to load, so we were then able to identify which Angular Controllers and Views were needed. From there it was a process of identifying which Angular Factories, Directives, Components, Services, and Filters were required for the views. Once we knew which dependencies our new app needed, we were able to include them in the apps entry file. Once the entry file was created we then created a new Webpack config that would generate a bundle for the app. Due to the tightly coupled nature of our Angular code, the new Webpack bundle was only about 25% smaller than the Monoliths bundle (We could get this down even further with some refactoring). To get the Angular app loading into our S-SPA container, all we had to was deploy the new JS bundle and push a small change to the container.
That was around May 2020. Now we are in October and you might be wondering what has happened in the meantime? Have we abandoned S-SPA and embraced the monolith? Don’t worry, we are still migrating away from the monolith. After we documented how to migrate Angular apps into our S-SPA container we focused on the biggest release of new features in Ed so far. We are also building all new features to work in the monolith and as standalone apps loaded by the S-SPA container. This sounds like a lot of work, but development now is hell of a lot easier than it was a year ago. We can’t say when we will move to our new architecture but looking back on where we were this time last year it’s no longer something hazy far on the horizon. It now feels like it’s something just within our grasp.