How We Significantly Improved Frontend Performance in Our Remix App
Here at CZI, we build software that accelerates progress in science and education. As part of that work, we value creating delightful and responsive user experiences. Fast applications make it easier for users to seamlessly achieve their goals.
I’m on the developer foundations team for education products, and one of our responsibilities is to review application performance, identify any issues, and make adjustments to ensure everything works smoothly. Here’s an example of a recent project and the process I used to identify a performance problem and implement a fix.
Problem
For some of our software development, we rely on Remix. For context, Remix is a full stack web framework with a server and browser runtime that both embraces the server/client model and leverages native browser features to provide quick page loads and a better user experience. However, one particular web application was slow. Like, very slow. And not because of Remix itself. It was slow to the point where navigating pages takes >1 second (see Figure 1 below).
It wasn’t always this way; especially during the early stages of development, the app was quite fast and responsive. Something in the application had changed and I wanted to figure out what was causing problems.
Investigation and Solution
Working with my teammate Ferdinand Cruz, another software engineer, we observed slow page load times during navigation (Figure 1). I pulled out the network developers tools and inspected the network activity during navigation.
Looking closely at Figure 2 we noticed that there were several repeated fetch requests to the same url. Specifically, requests
- 1?_data=root
- 1?_data=routes%2Fstudents%2Factivities%2Fgoal%2Fnew
These requests were fired when the loader functions fetched data. Looking at the list above, the first path was for the “root loader” function and the second path was for the “/students/activities/goal/new loader” function. It seemed like our loader functions were being called multiple times when we navigated around the web application.
This shouldn’t be happening. All the data had already been loaded when the form was first loaded and shouldn’t be reloaded/fetched as the user moved through each form step (Figure 1). So I dug a bit deeper into our loader functions to try to see why they were being called.
Fortunately for me, this was pretty easy with Remix. Remix allows route components to implement a shouldRevalidate function to optimize whether data should be reloaded after actions and for client-side navigations. This function can be especially useful when debugging and determining when and why route data is validating. I added some code to see what we could find!
/**
* Figure 3: A shouldRevalidate function exported from the
* file app/routes/students/activities/goal/new.tsx which
* logs the parameters the functions is passed.
*
* file: app/routes/students/activities/goal/new.tsx
*/
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
console.group('shouldRevalidate - students/activities/goal/new');
console.log(JSON.stringify(args, null, 4));
console.groupEnd();
return args.defaultShouldRevalidate;
};
I refreshed the page — /students/activities/goal/new/ — after adding this code, and clicked the “Get Started” button to see what got logged to the console.
The logs were pretty insightful! From the logs above, I saw that the POST requests to /api/events/ were causing our loaders to reload/fetch data because the value of defaultShouldRevalidate was true.
I proceeded to verify the assumption and commented out the code, which was responsible for sending these requests; then reloaded the page to see what would happen.
Yep, the assumption was right! Comparing the before (Figure 4) and after (Figure 5) showed that the code sending POST requests to /api/events/ definitely caused our loaders to reload/fetch data.
But why?
As Remix explains it, when using useFetcher().submit, Remix has some additional behavior…
When submitting with POST, PUT, PATCH, DELETE, the action is called first: after the action completes, the loaders on the page are reloaded to capture any mutations that may have happened, automatically keeping your UI in sync with your server state.
The key point here from the quote above is, “after the action completes, the loaders on the page are reloaded”.
This means if code is calling useFetcher().submit(), then by default all of the loaders active on the page are going to be revalidated. If working with nested routes, this can be the cause of a lot of extra network traffic. Route components can implement the shouldRevalidate function to control whether they are revalidated or not (as shown in Figure 3); however, this should be implemented with caution as it can lead to bugs if not careful!
Assuming you’ve made it all this way, perhaps you’re ready to know the fix! Here you go!
/**
* Figure 6: simplified implementation of useSentEvent which was
* causing performance issues
*
* Before (using useFetcher)
*/
import {useFetcher} from '@remix-run/react';
export function useSendEvent(defaultData: Record<string, string>) {
const fetcher = useFetcher();
return (eventData: Record<string, string>) => {
fetcher.submit(
{...defaultData, ...eventData},
{action: '/api/events', method: 'post'},
);
};
}
/**
* Figure 6: simplified implementation of useSentEvent which
* resolved performance issues
*
* After (using native fetch API)
*/
export function useSendEvent(defaultData: Record<string, string>) {
const fetcher = useFetcher();
return (eventData: Record<string, string>) => {
fetch('/api/events', {
body: new URLSearchParams({
userEvent: JSON.stringify({...defaultData, eventData}),
}).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
};
}
End Result
We identified the problem, found a solution, and implemented a fix.
In summary, a react hook we were using leveraged Remix’s useFetcher().submit function to send analytics data to an analytics endpoint (Figure 6). We’d call this hook when the user performed certain actions in the application (e.g. submitting a form and viewing pages). However, calling useFetcher().submit triggered all route loaders active on the page to revalidate their route data. We switched to using the native Fetch API (Figure 7), which resolved performance issues.
In Remix, useFetcher().submit is not really suited for sending analytics data and this function should not be used when updating data that’s not currently displayed in the UI.
Now that the performance issue is fixed, the application enables users to have a much more delightful and responsive experience. Seamless navigation helps users move more swiftly toward their goals of accelerating progress on key missions.