Performant Web Apps

Imagine a world where dynamic web applications are so responsive, they feel as fast as static sites served directly from a user’s local system. That level of speed would create a seamless and delightful user experience.

But is that even possible? Dynamic apps need to load HTML, CSS, JavaScript, retrieve data, render the UI, and often pull in more resources as users interact. So how do we achieve snappy performance, especially when screens depend heavily on server data? A “local-first” approach might help, but it’s not always feasible—and not every manager will agree to it.

I’ll share strategies I’ve learned from blogs, X (formerly Twitter), and YouTube videos to help close the gap and improve the user experience.

The Problem

Before jumping into performance strategies, let’s define the problem. The perfect user experience depends on factors like network speed, server latency, and data processing.

Our goal is simple: reduce loading states. We won’t focus on code optimization, data structures, or algorithms. Instead, we’ll explore solutions for SPAs that work across frameworks and can be extended to SSR apps.

Possible Causes

One major reason apps feel slow is the network waterfall. This is very well explained on Remix’s homepage. I encourage everyone to check it out if not already done. But how does a traditional app cause this? Common reasons I’ve seen in many codebases include:

  • Poorly written data fetching or async code

  • Fetch-as-you-render

  • Decoupled components that fetch their own data, leading to more fetch-as-you-render scenarios

Thus the above problem statement can be rewritten as reducing network waterfall

Possible Solutions

Tools these days are very good and when used together can handle some of the above scenarios if used properly.

Let's see some ways to resolve some of the mentioned above

Poorly written data fetching or async code

Let’s take a look at this function:

async function fetchUserData(userId: string) {
  const postsResponse = await fetch(`/api/posts/${userId}`);
  const posts = await postsResponse.json();

  const commentsResponse = await fetch(`/api/comments/${userId}`);
  const comments = await commentsResponse.json();

  return { comments, posts }
}

This function fetches a user’s posts and comments based on userId. However, it causes a network waterfall because the comments request waits for the posts request to finish.

We can improve it by fetching both at the same time:

async function fetchUserData(userId: string) {
  const [posts, comments] = await Promise.all([
    fetch(`/api/posts/${userId}`).then((response) => response.json()),
    fetch(`/api/comments/${userId}`).then((response) => response.json()),
  ]);

  return { posts, comments };
}

By using Promise.all, both API calls happen in parallel, removing the waterfall delay.

There are many methods available on Promise that can be useful in different scenarios

Not all API calls can be parallelized—some may depend on the results of others—but many don’t. For dependent requests, this optimization doesn’t apply, and further strategies would depend on specific cases (which we won’t cover here).

The example above doesn’t handle errors, as it’s just for demonstration.

fetch-as-you-render & render-as-you-fetch

This is an interesting one and is very very common in codebases. So, what is fetch-as-you-render and how does it cause delayed UI?

fetch-as-you-render is caused when data is fetched inside a component and the Fetch-as-you-render happens when data fetching is triggered inside a component, often during the render phase or in lifecycle methods like useEffect. This is common across many frontend frameworks such as Solid, Svelte, and Vue.

Imagine you have three nested components, each fetching its own data. This creates a scenario similar to poorly written data fetching: each component’s data fetch starts only after the component is rendered or mounted. This not only delays the rendering but can also cause additional delays as each fetch waits for the previous one to complete.

Let look at a piece of code:

function UserDataViewer() {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);
    fetchUser().then((_user) => {
      setUser(_user);
      setIsLoading(false);
    });
  }, []);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <>
      <Posts userId={user.id} />
      <UserCommentsViewer userId={user.id} />;
    </>
  );
}

function Posts({ userId }: { userId: string }) {
  const [posts, setPost] = useState<Post | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);
    fetchUserPosts(userId).then((_posts) => {
      setPost(_posts);
      setIsLoading(false);
    });
  }, []);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <PostsViewer posts={posts} /> // A static component to show Posts
  );
}

function UserCommentsViewer({ userId }: { userId: string }) {
  const [comments, setComments] = useState<Comment | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    setIsLoading(true);
    fetchUserComments(userId).then((_comments) => {
      setComments(_comments);
      setIsLoading(false);
    });
  }, []);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  return <Comments comments={comments} />; // A static component to show comments
}

The provided code demonstrates the fetch-as-you-render pattern, which can lead to delayed UI updates. Here’s how:

  1. UserDataViewer: It fetches user data and waits for it to complete before rendering the Posts and UserCommentsViewer component.

  2. Posts & UserCommentsViewer: Once UserDataViewer has fetched and set the user data, Posts fetches posts based on the user ID.

In this setup, each component fetches its data only after the previous component’s data is available, causing a cascading delay. Each fetch operation waits for the component to render or mount, leading to slower overall page load times.

Fetching data per component is a common pattern to have colocation but can lead to inefficiencies. Libraries like @tanstack/react-router and SWR simplify the process, but the fetch-as-you-render issue remains. Hoisting data fetching to a parent component helps, but what if that parent component is rendered within another component? How can we further improve this approach?

Render-as-you-fetch is a interesting pattern that can improve this situation. This can have its own blog, maybe I will write this in more details some other time, but let's directly jump to a solution.

Most frameworks provide data loaders integrated with their routers. For example, react-router-dom has loaders and actions, @tanstack/react-router offers loaders, @solidjs/router uses a load function, and @sveltejs/kit includes load function. Let’s explore how we can leverage react-router-dom and its loader and composition to improve data fetching efficiency.

export async function loader() {
  const { userId } = await fetchUser();

  const [posts, comments] = await Promise.all([
    fetch(`/api/posts/${userId}`).then((response) => response.json()),
    fetch(`/api/comments/${userId}`).then((response) => response.json()),
  ]);

  return { posts, comments };
}

function UserDataViewer() {
  const { posts, comments } = useLoaderData();
  return (
    <>
      <Posts posts={posts} />
      <UserCommentsViewer comments={comments} />
    </>
  );
}

The code above eliminates the waterfall effect by fetching data in the loader and passing it to the component as props. While this approach blocks user navigation until the data is fetched, you can adjust this behavior using Await, defer, and Suspense components. For more details, refer to the guide in the react-router-dom documentation.

But this above code has one problem, the data fetching is completely moved to loaders, and the colocation with component is lost. What if I want to drop UserCommentsViewer in another component in another route? I have to make sure that the data loader their fetches the data and correctly pass it to component, I cannot expect that I drop a component and it just works.

Let's improve this further using @tanstack/react-query

export async function loader() {
  const { userId } = await fetchUser();

  void queryClient.ensureQueryData(getPostsQueryOptions(user.id));
  void queryClient.ensureQueryData(getCommentsQueryOptions(user.id));

  return { userId };
}

function UserDataViewer() {
  const { userId } = useLoaderData();
  return (
    <>
      <Suspense fallback={<div>Loading...</div>}>
        <Posts userId={userId} />
      </Suspense>

      <Suspense fallback={<div>Loading...</div>}>
        <UserCommentsViewer userId={userId} />
      </Suspense>
    </>
  );
}

function Posts({ userId }: { userId: string }) {
  const { data } = useQuery(getPostsSuspenseQueryOptions(userId));
  return <div>{/* The actual code to render */}</div>;
}

function UserCommentsViewer({ userId }: { userId: userId }) {
  const { data } = useQuery(getPostsSuspenseQueryOptions(userId));
  return <div>{/* The actual code to render */}</div>;
}

This code tackles the waterfall issue while keeping the data fetching colocated with the component. The above code works as follows -

  1. Loader Function: Instead of just letting the components fetch their own data, the loader triggers the actual api call parallely. This prevents the waterfall. The code above will also render the route without waiting for the data to be fetched, this can be modified by awaiting them.

  2. Colocated Data Fetching: The Posts and UserCommentsViewer components use useQuery to access the already-fetched or ongoing fetch data. Since the loader has done the heavy lifting, these components can render themselves without worrying about the waterfall.

Another great thing about these components is that they’re self-contained. You can drop them anywhere in your React app, and they’ll work just fine. Of course, if you move the component, the route loader will need the prefetch logic too, but that’s easily handled.

There’s another cool benefit to using loaders for prefetching. Most routing libraries can prefetch data when a link is hovered over or rendered, meaning the data is ready before the user even clicks the link. This taps into the render-as-you-fetch pattern, where the component already has the data by the time it renders, eliminating the need for fetch calls inside the component.

I personally find this approach clean and efficient. I stumbled upon this while digging through the @tanstack/react-router docs. You’ll find a similar implementation in the Solid.js repo for Hacker News.

Of course, there are plenty of other reasons why web apps might feel slow, and frameworks are constantly rolling out new solutions. But these approaches are relatively easy to incorporate into existing codebases and can make a noticeable difference.

Hey everyone! This is my first blog, and I’d love to hear your suggestions and feedback to help me improve. I wrote this to share some solutions I’ve come across while working on codebases and tutorials, where I’ve noticed these common issues pop up. Let me know what you think!

0
Subscribe to my newsletter

Read articles from Shubhajit Chatterjee directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shubhajit Chatterjee
Shubhajit Chatterjee