From old to new

The New Training Log

Building a Micro Frontend App in a Month at Strava

Logan Medina
strava-engineering
Published in
10 min readSep 30, 2020

--

Background

The original Training Log was built and released in 2013. The log is a calendar of an athlete’s activities, represented as color-coded, scaled bubbles based on each activity’s type and stats. Weekly totals summarize the activity for each week and displays progress attained on that week’s goals. Written in Coffeescript, utilizing Backbone, D3 and jQuery, served from our Rails monolith, it supported runs, rides, and swims (with other activity types lumped together as cross-training) and workout type designations, like long run/ride and race, in an interactive, scrollable, page.

Screenshot of the original training log
The original Training Log.

Ultimately, the Training Log was so complex and unwieldy that it became difficult to fit any improvements to features or design in a product roadmap. The whole calendar was a series of stacked and translated svgs generated using D3, spread across ~30 files. This complexity meant that a significant portion of any update would be spent trying to understand each of the 30 files’ responsibilities with unintended side-effects arising from simple changes. Additionally, Backbone and jQuery code written in Coffeescript had become less popular over time and required significant context switching for our engineers and a fairly steep learning curve for new hires. As a result, over the past 7 years, there have been only minor additions or bug fixes, with the vast majority of the page remaining untouched.

In order to provide a better experience for our multi-sport athletes we knew that the Training Log was in need of an update. Athlete facing improvements were to provide a cleaner, more modern UI and first-class support for all of our various activity types; engineering goals were to also improve the codebase to make it more extensible.

Every frontend product decision at Strava these days involves discussions of deprecating our old frontend web technologies and, instead, rewriting our frontend as a series of individually deployable apps in a new web monorepo. This is quite the process, therefore we carefully evaluate which pages both require an update and can be migrated efficiently within a given product time frame.

After evaluating the work that needed to be completed to update the Training Log and discussing that scope of work between engineers and product stakeholders, we determined that the migration to our new stack would barely be within our thresholds of engineering time and effort for a planned product launch of a mobile Training Log. It became a perfect candidate to become a micro frontend app.

The Monorepo

The monorepo is designed to be a clean slate for the development of a new frontend app. Each app gets containerized with its own server and then deployed using Marathon. When a request comes in, we use Linkerd to selectively route the request by endpoint to either our micro frontend app or to our Rails monolith. Our frontend apps can then use our Rails app as an API to load data for both server-side rendering and client-side fetching. This allows us to incrementally move individual pages or groups of related pages to the new stack with a clean separation of technologies.

Image of a sample request diagram showing Linkerd routing to the Rails monolith and the micro frontend.
Example request diagram for routing to the monorepo.

While every app is customizable, the monorepo structure allows us to share packages and scripts between apps. Frontend teams can test out new patterns and libraries on an app-by-app basis, report back with evaluations of their effectiveness, and collaboratively decide what should be shared across all apps. Additionally, these packages will facilitate a consistent design system across apps; any changes in design to one package will automatically propagate to all of our apps.

Diagram of monorepo directory structure containing apps, packages, and scripts directories.
Directory structure of the monorepo.

Packages can also be exported as npm packages which allows for newly developed components and helpers to be imported and used in non-migrated apps while keeping their look and behavior consistent with the new frontend. Examples of packages include our UI component library, configurations for i18n and linting, diagnostic and monitoring tools, and widely used helpers. We have also decided to use React for all of our frontend development within the monorepo.

This freedom is a double-edged sword. It’s hard to know when an engineer will inadvertently establish a new pattern and, since the migration process is just beginning, there are few established patterns to reference. As such, the two teams actively working on developing within the monorepo established a series of tri-weekly office hours that could be used to discuss any ideas/concerns/problems that they were facing. This allowed for open communication and collaboration between teams and individual engineers.

The Training Log

The new Training Log.

The new Training Log is built in React utilizing the NextJS framework. NextJS offers library-specific benefits like plugins for internationalization and bundle analysis for server-side rendering. While it’s not a requirement for every frontend app to use NextJS, and there are pages where it might not be necessary to use, the framework has provided enough benefits and stability that we anticipate nearly all of our micro frontends will use it. Having that pattern established allowed us to collaborate more closely with another team that was simultaneously developing the new Routes app, which also utilized NextJS.

We strategically divided the work into three main themes: building the components that would make up the UI, creating a global navbar that could be used across monorepo apps, and managing the massive amounts of data that our athletes may be viewing.

Building a Functional UI

Much of our existing React code was written using class-based components and Redux because it was written before and immediately following the release of React v16.8, which introduced React Hooks. Since that time, Hooks have become more prominent and we decided early on that we would try to only use functional components utilizing React Hooks to manage our state whenever possible. We feel that this decision is more in line with official statements surrounding the recommended use of React and will be easier to understand, test, and reason about in the future, even though there was a slight learning curve to understand the best use of writing functional React.

Some of the main components of the Training Log App.

Despite clear documentation from React, React Hooks were especially tricky to implement with our initial component organization. Many of our component functions were designed to be very small and use JavaScript-only helpers to format and organize data. By hard-coding data within our helper functions, we were effectively setting state within them, which worked fine initially, but became an issue as we started using Hooks to conditionally format data or localize strings.

// DO NOT DO THISconst helperWithContext = (value) => {
const context = useContext(Context)
context.doSomething(value)
...
}

const Component = ({ foo }) => {
return(
<>
...
{ foo ? helperWithContext(foo) : -- }
...
</>
)
}

Hooks are designed to manage state at the top level component. React expects them to be called the same number of times and in the same order between renders. By using hooks within conditionally called helper functions, we ended up varying the number of times the hook was called and altering the state of our component in ways that React couldn’t understand. There were two solutions, one was to call the hook in the top level component and then pass it into the helper function or, alternatively, we could call the hook in the top level and move our helper within the same scope, nesting it within our main component.

// DO THIS INSTEADconst helperPassedContext = (value, context) => {
context.doSomething(value)
...
}

const Component = ({ foo, bar }) => {
const context = useContext(SomeContext)
return(
<>
...
{ foo ? helperPassedContext(bar, context) : -- }
...
</>
)
}

As a consequence of hooks, our components ended up growing larger and encompassing more logic, while our helper functions ended up becoming either nested functions within our component or pure JavaScript helpers. Overall, we found the usage of hooks to be more of a mental shift than anything particularly difficult to use and it also promoted a clean separation of React functions from regular JavaScript functions.

D3.js is a library that allows for data to be bound to the DOM and that can then transform the DOM from manipulating that data. This poses a challenge to React, which we feel should be the owner of the DOM. In order to resolve that conflict, we avoided using the select, append, enter or exit methods from D3, and instead only use `d3-scale` to provide the logarithmic scale with which the bubbles are sized. The rest of the SVG including fill, patterns, labeling, or whether it should even appear on the page, is all controlled by React. That way, we avoid any tug of war between D3 and React for control of the DOM.

In the end, the Training Log consists of 15 custom components that are unique to the app and it utilizes a number of components from our internal UI library.

Composites

The global navbar composite, wrapping the Training Log, with notification menu open.

The global navbar provided its own unique difficulties. As mentioned before, the monorepo contains a packages directory that is shared between apps and can also be exported as a library to other repos. But what happens if a component has a dependency on a framework like NextJS, a framework that may not be available in every place we might import it? We decided that there was a class of components, which we called a composite, that was meant to be shared between apps, but only those apps within the monorepo that utilize NextJS.

Diagram of directory structure containing apps, packages, scripts, and composites.
Directory structure of the monorepo with composites.

The navbar was our first composite. It is a functional component that accepts a set of props, manages state using React Hooks, and implements a few behaviors around notifications. It pushes the boundaries of a base component by requesting its own initial, server-side props using NextJS. To help integrate this new composite within an app, it exports a higher-order component, taking any other app as an argument, then merges that app’s call to get initial props with its own, ultimately returning a React fragment that contains the global navbar and the containing app.

function withGlobalHeader(WrappedPage) {
const WithGlobalHeader = ({ ...props }) => {
return (
<>
<GlobalHeader {...props} />
<WrappedPage {...props} />
</>
);
};
WithGlobalHeader.getInitialProps = async (context) => {
const componentInitialProps =
await WrappedPage.getInitialProps(context);

return {...componentInitialProps};
};
return WithGlobalHeader;
}
export default withGlobalHeader;

Any app that needs the global navbar can easily be wrapped in that higher-order component without needing to know anything about the inner-workings of the navbar itself.

Data Management

From the first conversations around our training log implementation we knew that we would eventually run into performance issues with the large number of activities that many of our athletes have completed. Just rendering thousands of activities could be a performance headache, but we also wanted our athletes to have the ability to scroll infinitely into their training history or use the timeline functionality to jump to a specific date in the past.

Initial tests with some of our power-user employees showed that requesting all of an athlete’s activities on page load took longer than we were willing to allow. The easiest solution was to load the first few months of an athlete’s activity history on page load and populate the rest with placeholder rows. Indexing activity data by timestamp allows us an efficient lookup of a week’s row, regardless of whether the data has been loaded or not.

In order to track each week’s rows we use the Intersection Observer API. On first render, we attach a ref to each entry. We then target each entry ref to be observed by our Intersection Observer. As entries enter and exit the viewport, we can use the events triggered by the Intersection Observer callback to set the currently viewed entry timestamp. When clicking on the timeline, we can then scroll the page down or up until we are sure that our desired entry is in the viewport. The reverse also occurs, scrolling up and down triggers the callback, which can be used to set the currently viewed year and month on the timeline.

Unloaded weekly entries showing the loading state.

When scrolling, if we have the week’s data loaded, it’s easy to reference it and render it. Otherwise, if a placeholder row enters the viewport, we reference its timestamp, make a request for that week’s activities, and replace the placeholder data with the actual data. This allows us to infinitely scroll without requiring that all the data be loaded in-between. Additionally, having the data indexed by timestamp also allows us to use the timeline to jump forward or backward in time. When clicking on the timeline, if the data hasn’t previously been loaded, we populate it, then jump to that row in the calendar view.

The Future

The new Training Log frontend was built, from scratch, by a team of three engineers in one month before it was released publicly to millions of athletes. Since its release, we have already been able to easily update it to accommodate a new backend structure for goals and released Relative Effort as a metric for bubble sizing to help athletes to truly understand their training, each within a matter of hours rather than days/weeks to implement the same features on the old Training Log.

The contained nature of the apps, componentized nature of React, and the paring down of languages to just JavaScript, CSS, and HTML has proven to be extremely helpful in expediting development for frontend engineers. Reducing the amount of technical overhead surrounding frontend development has also provided a safer and more understandable environment for backend engineers to feel comfortable making changes as well. Its success, coupled with the Routes app, has been a great indicator that moving to micro frontends is a winning strategy for simplifying and upgrading the entire Strava frontend incrementally.

Special thanks to the other members of the Training Log engineering team: Brian Rogers and Erick Herrarte; as well as the Routes project team: Alexandre Kirillov, Mark Hurwitz, and Will Schuur for their wealth of knowledge and support.

--

--