4 things I learned about optimizing a web app

Rewiring your code can make all the difference

August 22, 20228 min read
4 things I learned about optimizing a web app banner

In the modern web, speed is power. A very important, fundamental concept that hinges on performance is user experience. Of course, users would want to keep using your app and its many features if it keeps up with how it's being used. Your homepage takes 30 seconds to load? Eh, I'll pass on that. Users can get more done if the app they're using is fast and performant. If users get more things done, then they're likely to be happy, returning users.

Recently, I've had the chance to work on a web app which tackled various performance issues. I actually haven't experienced working on optimizing any app's performance prior to this, so this really gave me a lot of insight on what goes behind making a web app blazingly fast. Frankly, I had a lot of fun digging through what made some parts of our app slow. The code below might be specific to JavaScript, but concepts can apply to whatever framework you're using.

1. Benchmark the performance

Before you go around pre-optimizing your app, it might be best to first observe it as a regular user. Interact with your app, navigate around. Is your page struggling to load data? Is your UI janky? If any of these questions are a clear yes, then it might be time to benchmark it.

In NodeJS, The most basic form of benchmarking I learned was to use console.time() and console.timeEnd(). You first call console.time() with a string argument that identifies that timer, then console.timeEnd() with the same string identifier to end it. The output would be your string identifier, followed by the time the code in between those two lines ran. Here's an example:

console.time("analyzing - doSomethingVeryIntensive");

doSomethingVeryIntensive();

console.timeEnd("analyzing - doSomethingVeryIntensive");

// analyzing - doSomethingVeryIntensive: 3421.22705078125 ms

You can use the browser's profiling tools to benchmark your UI's performance. We primarly use React in our project, so the most efficient way to benchmark React components is to use the React Profiler from the React Developer Tools extension. You'll easily be able to determine which components are chonky and taking up too much time to render.

React Profiler

Now that you've determined what's slow, you can then spend some time figuring out why exactly it's slowing down.

2. Take advantage of parallelization

Suppose you have the following code, can you tell what exactly can be improved here?

const response1 = await fetchPaginatedData(params); // takes 1000ms to resolve
const response2 = await fetchCount(params); // also takes 1000ms to resolve

In the code above, we're executing two independent pieces of async code in sequence. Each API call takes 1000ms to finish. If we run these two sequentially, the total amount of time it would take to finish is 2000ms (1000ms * 2).

The easiest way to optimize this is by using Promise.all() in JavaScript. Promise.all() takes in an array of promises and executes them all in parallel. The result is an array which contains the result of each promise, with the order being the order of promises you passed in the argument.

const responses = await Promise.all([
  fetchPaginatedData(params),
  fetchCount(params),
]);

// responses[0] == result of fetchPaginatedData()
// responses[1] == result of fetchCount()

Since we're executing these in parallel, the total time this will take will only be 1000ms. We just reduced the time by half!

Do note that this applies to sequential async code which are independent of one another, i.e. the response of the first API call is not used by the next.

3. Reduce the number of HTTP requests you're making

It's generally a consensus in performance optimization that the more code you're executing, the slower the app would get. This also rings true for making HTTP requests. The more requests you make, the more time your app takes to load.

A common scenario we previously had in our project was we were making a lot of unnecessary requests to the backend. Here's an example:

// DatasetPage.tsx
function DatasetPage(props) {
  const { data: tagInfo, isFetching: isTagInfoLoading } = useGetTagInfoQuery(props.datasetId); // calls /api/tags
  const { data: geoRegionInfo, isFetching: isGeoRegionInfoLoading } = useGetGeoRegionInfoQuery(props.datasetId); // calls /api/geo-regions
  const { data: sectorInfo, isFetching: isSectorInfoLoading } = useGetSectorInfoQuery(props.datasetId); // calls /api/sectors

  // ...

  const isDatasetPageLoading = isTagInfoLoading || isGeoRegionInfoLoading || isSectorInfoLoading;
  
  // ...

  if (!isDatasetPageLoading) {
    // display tagInfo, geoRegionInfo, and sectorInfo here
  }
}

Here's a component with 3 API calls to retrieve different kinds of information given a "dataset ID" from the database. This was a fine design choice at first since each API call had its' own responsibility. But what we realized a bit later from business logic was that separating each one didn't really make sense. All of them were practically doing the same thing–getting a piece of information from the database given an identifier. Why don't we just bundle them all up into a single endpoint, then?

// /api/get-metadata/index.ts
export async function getMetadata(req: Request, res: Response) {
  try {
    const { datasetId } = req.params;

    // parallelize database queries and bundle them into one response
    const [tags, geoRegions, sectors] = await Promise.all([
      db.getTags(datasetId),
      db.getGeoRegions(datasetId),
      db.getSectors(datasetId),
    ]);

    // format the response object
    const response = {
      tags,
      geoRegions,
      sectors
    };

    // send it back to the client
    res.send(response).status(OK)
  } catch (e: Err) {
    // ...
  }
}

// DatasetPage.tsx
function DatasetPage(props) {
  const { data: metadata, isFetching: isMetadataLoading } = useGetMetadataQuery(props.datasetId) // calls /api/get-metadata
}

There are some obvious benefits from this refactor: First, the loading time was better since we weren't waiting for three separate API calls to finish. Second, we didn't need those three extra endpoints anymore, replacing them with the new one. Lastly, the UI code lost quite a bit of bloat!

(07/2023) Correction: In this example, I was using react-query for multiple data fetching which by default already does fetching in parallel. But, you know... you get the idea about reducing unnecessary API calls :D

4. Use caches

A cache is a storage mechanism that stores data so subsequent requests for that data can be served faster. A performance best practice is to cache network requests as much as possible.

Cache-Control header

The Cache-Control header is a HTTP header which instructs the browser how to handle the caching for a specific network request.

We used this in the response object of an API endpoint whose result wouldn't change for quite some time, significantly reducing the round-trip time.

res.set("Cache-Control", "public, max-age=5000"); // caches the response for 5000 seconds

Data fetching libraries (React Query/useSWR)

Modern React data fetching libraries such as React Query and useSWR simplify caching in your React app. These packages provide not only all the benefits of HTTP caching, but also great developer experience out of the box. Plain 'ol data fetching with Fetch and Axios is fine, but when you want to improve your app's performance using caching, these libraries just wouldn't cut it anymore.

In our React app, all of the data fetching is done with React Query. It's also super convenient to just create reusable hooks, then plug them in whichever component needs to fetch that data.

// userInfoQueries.ts
function useUserInfoQuery(params: UserInfoParams) {
  return useQuery([USER_INFO_KEY], UserApi.getUserInfo(params));
}

// UserInfo.tsx
function UserInfo(props: UserInfoProps) {
  const { data: userInfo, status, error } = useUserInfoQuery(props.userId);

  // ... other UserInfo stuff here
}

Redis

Redis is a scalable in-memory store, typically used as a database, cache, message broker, and streaming engine. In our app, we use the Node-Redis NPM package to store and cache session and user information. How it functions as a cache is that you simply set a value with a unique key, and get the value with the same key whenever you need it. You can also set an expiry time for when Redis would invalidate the cache.

// Basic Redis example
import { createClient } from "redis";

const client = createClient();

client.on("error", (err) => console.log("Redis Client Error", err));

await client.connect();

await client.set("key", "value", {
  EX: 180, // expire the cache in 180 seconds
  NX: true, // only set the value for the key if it doesn't already exist
});
const value = await client.get("key");

Conclusion

Your app's performance can make or break its' user experience. Investing a bit of time on tweaking your code for the better can really help make a difference. Performance isn't a one-time thing, of course. As your app continues to scale, it's important to continuously monitor its' performance and apply some of these concepts as necessary.