Mazzarolo MatteoMazzarolo Matteo

Flexible network data preloading in large SPAs

By Mazzarolo Matteo


Disclaimer: This post focuses on custom solutions to improve the performance of client-side rendered SPAs. If you’re using frameworks like Next.js, Remix, or similar, these optimizations are typically handled for you automatically :)

In my experience with implementing client-side rendering, one important optimization is preloading network data on page load. From what I've seen in my last three companies, large SPAs typically require a series of network requests at page load. For example, to load user authentication data, environment configuration, etc.

When you start writing React applications, these network requests are usually initiated after the React app is mounted. And, while this approach does work, it can become inefficient as the application scales. Why wait for the app bundle to be downloaded, parsed, and for the React app to be loaded to start network requests when you know you can run them in parallel with these steps?

Preloading network requests

Modern browsers offer tools like link rel="preload" and other resource hints to handle these specific use cases: they can be used to kickstart necessary network requests as soon as possible. However, these are mainly limited to simple, hardcoded requests. For more complex scenarios, you might need to rely on existing framework solutions or create a custom implementation.

In cases where the only option is to build a custom solution, my preferred method involves injecting a small JavaScript script into the HTML document’s head to start network requests immediately. Unlike browser hints, this script is entirely under your control, enabling more complex behaviors such as conditional requests, request waterfalls, handling WebSocket connections, etc.

Basic Implementation

As an example, here's a tiny example of how to preload the network requests needed to load some user data:

index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <script>
        // Simplified version just to showcase how preloading looks like 
        // from a high-level.
        window.__userDataPromise = (async function () {
            const user = await (await fetch("/api/user")).json();
            const userPreferences = await (await fetch(`/api/user-preferences/${user.id}`)).json();
            return { user, userPreferences };
        })();
    </script>
</head>
<body>
    <script src="/my-app.js"></script>
</body>
</html>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <script>
        // Simplified version just to showcase how preloading looks like 
        // from a high-level.
        window.__userDataPromise = (async function () {
            const user = await (await fetch("/api/user")).json();
            const userPreferences = await (await fetch(`/api/user-preferences/${user.id}`)).json();
            return { user, userPreferences };
        })();
    </script>
</head>
<body>
    <script src="/my-app.js"></script>
</body>
</html>
my-app.js
// Again, very naive approach. In a real app you'd probably use something like
// React-Query or similar utils to consume the promise.
function MyApp() {
  const [userData, setUserData] = useState();
 
  async function loadUserData() {
    setUserData(await window.__userDataPromise);
  }
 
  useEffect(() => {
    loadUserData();
  }, []);
}
my-app.js
// Again, very naive approach. In a real app you'd probably use something like
// React-Query or similar utils to consume the promise.
function MyApp() {
  const [userData, setUserData] = useState();
 
  async function loadUserData() {
    setUserData(await window.__userDataPromise);
  }
 
  useEffect(() => {
    loadUserData();
  }, []);
}

This approach works for simple use cases but can become cumbersome as the app grows. For example, more often than not the flows that you're going to preload will be flows that you're going to re-invoke at runtime in your app: in the case above, for example, you'll probably want to re-fetch the user and config data after the user logs in again or changes the account.

A more "scalable" preload pattern

To address this, the pattern I've been using the most is to allow any function in your app to be made “preloadable.” The high-level steps are:

  1. Define the function to preload in your SPA’s code.
  2. Wrap the function with a withPreload API and export it.
  3. Import and kickstart the preloading in the preload script.
  4. At runtime, the function checks for preloaded results before executing.

Implementation

Here's a simplified code example of how this pattern can be implemented:

my-app/data-preloader.ts
/**
 * `DataPreloader` is a utility to preload data ASAP and consume it when needed.
 * For example, it can be used to preload data such as the user info and config
 * even *before* rendering the app, avoiding the need to wait for the UI to render
 * before fetching the data and avoiding waterfall effects.
 *
 * The `withPreload` function is a higher-order function that can be used to wrap a
 * function that you want to preload data for.
 * It returns a new function that, when called, will either return the preloaded
 * promise (if it exists) or call the original function. The returned function also
 * has a preload method that can be used to start preloading the data.
 *
 * This allows you to preload data in one part of your code and consume it in another part,
 * without having to worry about whether the data has already been preloaded.
 * If the data has been preloaded, the preloaded promise will be returned;
 * otherwise, the original function will be called.
 */
type PreloadEntry<T> = {
  id: string;
  promise: Promise<T>;
  status: "pending" | "resolved" | "rejected";
  result?: T;
  error?: unknown;
};
 
class DataPreloader {
  private entries: Map<string, PreloadEntry<unknown>>;
 
  constructor() {
    // If this is invoked on the SPA's code, we rehydrate it with the promises
    // created in the preload script.
    if (window.__dataPreloader_entries) {
      this.entries = window.__dataPreloader_entries;
      // If this is the preload script, expose the promises on the window object.
    } else {
      this.entries = new Map();
      window.__dataPreloader_entries = this.entries;
    }
  }
 
  // Kickstart a promise and store it in the global list tracked promises.
  preload<T>(id: string, func: () => Promise<T>): Promise<T> {
    const entry: PreloadEntry<T> = {
      id,
      promise: func(), // This is what kickstarts the preloading
      status: "pending",
    };
    // These are mostly added for introspection if you want to check the
    // promise status without awaiting it.
    entry.promise
      .then((result) => {
        entry.status = "resolved";
        entry.result = result;
      })
      .catch((error) => {
        entry.status = "rejected";
        entry.error = error;
      });
    this.entries.set(id, entry);
    return entry.promise;
  }
 
  // If a preload exist for a given promise, return its result and delete the
  // promise from the list (to ensure we don't return stale data).
  // An opportunity for improvement here could be to use the preloaded promise
  // only if it was preloaded "recently" -- again, to avoid stale data.
  consumePreloadedPromise<T>(id: string) {
    const preloadEntry = this.entries.get(id);
    if (preloadEntry) {
      this.entries.delete(id);
      return preloadEntry.promise as Promise<T>;
    }
  }
}
 
// Export this as a singleton
const dataPreloader = new DataPreloader();
 
// Another opportunity for improvement here is to allow passing parameters to 
// the function. This would require serializing the parameters to a string and 
// using that as the key to ensure that we don't match a preload that was done 
// with different parameters, for example.
export const withPreload = <T,>(id: string, func: () => Promise<T>) => {
  const preloadableFunc = () => {
    const promise = dataPreloader.consumePreloadedPromise<T>(id);
    if (promise) {
      return promise;
    } else {
      return func();
    }
  };
  // Expose a "preload" method on function so that it 
  // can be invoked to kickstart its preloading.
  preloadableFunc.preload = () => dataPreloader.preload(id, func);
  return preloadableFunc;
};
my-app/data-preloader.ts
/**
 * `DataPreloader` is a utility to preload data ASAP and consume it when needed.
 * For example, it can be used to preload data such as the user info and config
 * even *before* rendering the app, avoiding the need to wait for the UI to render
 * before fetching the data and avoiding waterfall effects.
 *
 * The `withPreload` function is a higher-order function that can be used to wrap a
 * function that you want to preload data for.
 * It returns a new function that, when called, will either return the preloaded
 * promise (if it exists) or call the original function. The returned function also
 * has a preload method that can be used to start preloading the data.
 *
 * This allows you to preload data in one part of your code and consume it in another part,
 * without having to worry about whether the data has already been preloaded.
 * If the data has been preloaded, the preloaded promise will be returned;
 * otherwise, the original function will be called.
 */
type PreloadEntry<T> = {
  id: string;
  promise: Promise<T>;
  status: "pending" | "resolved" | "rejected";
  result?: T;
  error?: unknown;
};
 
class DataPreloader {
  private entries: Map<string, PreloadEntry<unknown>>;
 
  constructor() {
    // If this is invoked on the SPA's code, we rehydrate it with the promises
    // created in the preload script.
    if (window.__dataPreloader_entries) {
      this.entries = window.__dataPreloader_entries;
      // If this is the preload script, expose the promises on the window object.
    } else {
      this.entries = new Map();
      window.__dataPreloader_entries = this.entries;
    }
  }
 
  // Kickstart a promise and store it in the global list tracked promises.
  preload<T>(id: string, func: () => Promise<T>): Promise<T> {
    const entry: PreloadEntry<T> = {
      id,
      promise: func(), // This is what kickstarts the preloading
      status: "pending",
    };
    // These are mostly added for introspection if you want to check the
    // promise status without awaiting it.
    entry.promise
      .then((result) => {
        entry.status = "resolved";
        entry.result = result;
      })
      .catch((error) => {
        entry.status = "rejected";
        entry.error = error;
      });
    this.entries.set(id, entry);
    return entry.promise;
  }
 
  // If a preload exist for a given promise, return its result and delete the
  // promise from the list (to ensure we don't return stale data).
  // An opportunity for improvement here could be to use the preloaded promise
  // only if it was preloaded "recently" -- again, to avoid stale data.
  consumePreloadedPromise<T>(id: string) {
    const preloadEntry = this.entries.get(id);
    if (preloadEntry) {
      this.entries.delete(id);
      return preloadEntry.promise as Promise<T>;
    }
  }
}
 
// Export this as a singleton
const dataPreloader = new DataPreloader();
 
// Another opportunity for improvement here is to allow passing parameters to 
// the function. This would require serializing the parameters to a string and 
// using that as the key to ensure that we don't match a preload that was done 
// with different parameters, for example.
export const withPreload = <T,>(id: string, func: () => Promise<T>) => {
  const preloadableFunc = () => {
    const promise = dataPreloader.consumePreloadedPromise<T>(id);
    if (promise) {
      return promise;
    } else {
      return func();
    }
  };
  // Expose a "preload" method on function so that it 
  // can be invoked to kickstart its preloading.
  preloadableFunc.preload = () => dataPreloader.preload(id, func);
  return preloadableFunc;
};
my-app/load-user-data.ts
import { fetchUser, fetchUserPreferences } from "./api";
import { getUserAuthToken } from "./auth";
import { withPreload } from "./data-preloader";
 
type UserData =
  | {
      isLoggedIn: false;
    }
  | { isLoggedIn: true; user: User; userPreferences: UserPreferences };
 
const _loadUserData = async () => {
  const userAuthToken = await getUserAuthToken();
 
  if (!userAuthToken) {
    return { isLoggedIn: false };
  }
 
  const user = await fetchUser();
 
  const userPreferences = await fetchUserPreferences();
 
  return { isLoggedIn: true, user, userPreferences };
};
 
// To make the function above preloadable, just wrap it with `withPreload` and
// assign to it an ID unique across your SPA.
const LOAD_USER_DATA_PRELOAD_ID = "loadUserData";
export const loadUserData = withPreload(
  LOAD_USER_DATA_PRELOAD_ID,
  _loadUserData,
);
my-app/load-user-data.ts
import { fetchUser, fetchUserPreferences } from "./api";
import { getUserAuthToken } from "./auth";
import { withPreload } from "./data-preloader";
 
type UserData =
  | {
      isLoggedIn: false;
    }
  | { isLoggedIn: true; user: User; userPreferences: UserPreferences };
 
const _loadUserData = async () => {
  const userAuthToken = await getUserAuthToken();
 
  if (!userAuthToken) {
    return { isLoggedIn: false };
  }
 
  const user = await fetchUser();
 
  const userPreferences = await fetchUserPreferences();
 
  return { isLoggedIn: true, user, userPreferences };
};
 
// To make the function above preloadable, just wrap it with `withPreload` and
// assign to it an ID unique across your SPA.
const LOAD_USER_DATA_PRELOAD_ID = "loadUserData";
export const loadUserData = withPreload(
  LOAD_USER_DATA_PRELOAD_ID,
  _loadUserData,
);
my-app/app.tsx
// In any part of your app, you can use `loadUserData` as it is, without worrying
// about if the data has been preloaded or not.
const userData = await loadUserData();
my-app/app.tsx
// In any part of your app, you can use `loadUserData` as it is, without worrying
// about if the data has been preloaded or not.
const userData = await loadUserData();
my-app/preload-script-entry-point.ts
/**
 * This file is the entry point for the data preloader.
 * It's injected as a separate script from the rest of the SPA so that
 * it can start preloading data as soon as possible.
 * You'll generally want to use a bundler like Webpack to ensure this is split
 * into its own file.
 */
import { loadUserData } from "./load-user-data";
 
(async function run() {
  await loadUserData.preload();
})();
my-app/preload-script-entry-point.ts
/**
 * This file is the entry point for the data preloader.
 * It's injected as a separate script from the rest of the SPA so that
 * it can start preloading data as soon as possible.
 * You'll generally want to use a bundler like Webpack to ensure this is split
 * into its own file.
 */
import { loadUserData } from "./load-user-data";
 
(async function run() {
  await loadUserData.preload();
})();

Here we’re using withPreload to preload user data, but you can easily extend this to preload any other information. Just wrap the function you want to preload in withPreload and kickstart it in the preload script. Additionally, you can add custom logic to the preload script to determine if the preload should be triggered based on factors like the URL, cookies, local storage, etc.

Benefits and Considerations

As I mentioned, this is a simplified example of how this pattern works, and there are many ways to enhance it further, such as adding preload expiration logic and supporting parameter matching for withPreload. Generally, this pattern has been effective for my use cases, but it’s important to note that it’s not a one-size-fits-all solution. Be cautious to ensure that the functions you import in the preload script don’t introduce excessive dependencies, as this can lead to a script that’s so large that downloading and parsing it becomes less efficient than the preloading itself.

Feel free to adapt this version further to match your style and use cases :)