Make Your Single Page App Feel Faster

post image

Most apps todays are using some sort of backend api to display data on their pages. Those requests might take a while to load and this can heavily impact user experience. As a Frontend developers we usually can’t make the backend faster but we can take other measures to make the app feel faster when the backend is a bit slow.

Avoid Requests Waterfalls

A requests watefalls happen when a fetch request is declared in component which is not rendered yet, beacuase the component rendering is waiting for another fetch request to finish. This creates a problem the user have to wait for both requests to finish instead just the longer one. A good way to detect this problem is just to open the network panel in devtools and watching the fetch requests in our app. To illustrate the problem better let’s see a simplified example:

UserPage.tsx
function UserPage({ id }) {
  const [user, setUser] = useState<any>();
 
  useEffect(() => {
    // fetch User on load
    getUser(id).then((data) => setUser(data));
  }, []);
 
  if (user) {
    return (
      <div>
        <div>{user.name}</div>
        <UserActivities userId={id}/>
      </div>
    );
  }
 
  return <div>loading...</div>;
}
 
export function UserActivities({ userId }) {
  const [activites, setActivities] = useState<any>();
 
  useEffect(() => {
    // activities request wont get triggered until the component is rendered!
    getUserActivities(userId).then((data) => setActivities(data));
  }, []);
 
  if (activites) {
    return (
      <div>
        {activites.map((x: any) => (
          <div key={x.id}>{x.title}</div>
        ))}
      </div>
    );
  }
 
  return <div>loading activites...</div>;
}

As we can see getUserActivities request is not triggered until getUser is done loading.

So how can we fix it?

We want to load user activites together with getUser so we will move getUserActivities inside the UserPage useEffect hook, and pass activities data as prop to the UserActivities component. This should be a quick fix to our problem here, but apps nowdays are much more complex, a page can contain several different requests at once and each of them can be connected to deeply nested component. Luckily for us we can use existing solutions to help with this problem, for example React Query & React Router.

we can declare all our fetching requests inside the loader prop on each route, and we can read each request data anywhere in the page component tree using the useQuery API. This way all requests are guaranteed to trigger sooner and on the same time and we also benefit from caching for future identical requests. So for our senario a basic partial implementation will look like this:

UserPageExample.jsx
const queryClient = new QueryClient();
 
export const userLoader = ({ params }) => {
  return queryClient.fetchQuery({
    queryKey: ["users", params.userId],
    queryFn: fetchUser,
  });
};
 
export const activitiesLoader = ({ params }) => {
  return queryClient.fetchQuery({
    queryKey: ["users", params.userId, "activities"],
    queryFn: fetchActivities,
  });
};
 
function UserPage() {
  const { userId } = useParams();
  const {
    data: user,
    error,
    isLoading,
  } = useQuery(["user", userId], fetchUser);
 
  // render rest of jsx
}
 
function UserActivities() {
  const { userId } = useParams();
  const {
    data: user,
    error,
    isLoading,
  } = useQuery(["user", userId, "activities"], fetchActivities);
 
  // render rest of jsx
}
 
const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    children: [
      {
        path: "user",
        element: <UserPage />,
        loader: userLoader,
        children: [
          {
            path: "user/activities",
            element: <UserActivities />,
            loader: activitiesLoader,
          },
        ],
      },
    ],
  },
]);

Even if your not using those libraries the big takeaway is this - fetch your data as early as possible and higher in the component hierarchy.

Taking it even further for data critical requests we can initiate the fetch inside the <head> tag and store the promise on window and read it later in the component. This way the request starts even before react is loaded!

Cache Previous Requests

When a user navigates through an app he may cross a certain piece of UI several times which will require fetching the same data again. Would’nt be smarter to store those fetch responses in some cache? In React world we will usually use an exisiting solution such React Query, SWR, RTK Queries (redux toolkit). This solutions usually store each response in cache key which could be used later if data is not stale or deleted. Another solution will be using the browser cache headers to mark content for caching, altough invalidating data with this approach will be harder.

Prefetch Data

Sometimes we can predict what the user will do on the page before he actually do it. For example a table with pagination we can prefetch the next page before the user click the next button, when the user finally clicks the next button he will see the new data immediately. Implementing request prefetch and cache it might be complicated but luckliy for us we got Tanstack Query (Again) for the rescue, using the the pretchQuery api. a good spot for triggering prefetch is when user hovers a link button we can trigger next page prefetching.

Use Skeletons instead of loading spinners when possible

Loading spinners usually gives the user the feeling he has to wait for something, on the other hand using a skeleton will give the user time to proccess how is the page gonna look?. Another benefit is reduced layout shifts when the data finally loads which can be distrupting.