Sergio Xalambrí

Avoid waterfalls of queries in Remix loaders

Remix does a fantastic job of avoiding waterfalls everywhere. It preloads assets. It downloads JS and data simultaneously when navigating to another route. It runs all your loaders parallel in the first request and after form submission.

But, there's an area where it can't help you avoid waterfalls, inside your loaders, or actions, code, because, well, you coded it. Remix only calls the loader, so it's your work to prevent waterfalls there.

Let's see an example of a loader with a waterfall of requests. Let's say we have a few functions get* that do different requests and return the data directly instead of the response just to simplify the example.

export let loader: LoaderFunction = async ({ params }) => {
  let user = await getUser();
  let post = await getPost(params.postId);
  let comments = await getComments(params.postId);
  let notifications = await getNotifications(user.id);

  return { user, post, comments, notifications };
};

Why is this causing a waterfall? The await makes the code block wait for the response of the first request, then it's waiting for the response of the second request, and so on.

A first attempt to fix that waterfall is to parallelize as much as possible with Promise.all.

export let loader: LoaderFunction = async ({ params }) => {
  let [user, post, comments] = await Promise.all([
    getUser(),
    getPost(params.postId),
    getComments(params.postId),
  ]);

  let notifications = await getNotifications(user.id);

  return { user, post, comments, notifications };
};

You avoided a considerable part of the waterfall because the first three requests ran parallel. Still, the last one is waiting for the response of the first three requests when it only really need the reaction of one (getUser), so we can do better.

export let loader: LoaderFunction = async ({ params }) => {
  let postPromise = getPost(params.postId);
  let commentsPromise = getComments(params.postId);

  let user = await getUser();

  let [post, comments, notifications] = await Promise.all([
    postPromise,
    commentsPromise,
    getNotifications(user.id),
  ]);

  return { user, post, comments, notifications };
};

What is that doing? You can see neither getPost nor getComments are await-ed. This means we stored the promise in a variable, allowing the rest of the code to continue while the promise is completed.

Then we await getUser. Why? Because the getNotifications depends on it, remember we already triggered the requests for getPost and getComments. Hence, we run the three requests at the same time actually.

Finally, we make a Promise.all where we wait for the post and comments promises to be resolved. We also start the getNotifications request using the data from the user.

That final thing works because it's the last line before returning, so we can safely run all of these promises in parallel at this point. The first two will be resolved immediately, most likely because, by the time that runs, they should have been completed or near to, so we are probably only going to wait for the last one.

A different approach, that wouldn't work, but you may be tempted to do is to run getPost and getComments inside the Promise.all, like this:

export let loader: LoaderFunction = async ({ params }) => {
  let user = await getUser();

  let [post, comments, notifications] = await Promise.all([
    getPost(params.postId),
    getComments(params.postId),
    getNotifications(user.id),
  ]);

  return { user, post, comments, notifications };
};

This has a similar issue to the first attempt to solve it. We are making getPost and getComments wait for getUser to be completed, even if they do not depend on the result of that request.

Another approach Ryan Florence has suggested me and it's really nice is to create functions to combine the dependant requests.

export let loader: LoaderFunction = async ({ params }) => {
  async function getUserAndNotifications() {
    let user = await getUser();
    let notifications = await getNotifications(user.id);
    return { user, notifications };
  };

  let [{ user, notifications }, post, comments] = await Promise.all([
    getUserAndNotifications(),
    getPost(params.postId),
    getComments(params.postId),
  ]);

  return { user, post, comments, notifications };
};

This way, we can still run most things in parallel, while dependant parts run sequentially.

The only concern with this approach is in case you need the user for more than notifications, you will need to share the user or request it twice or more. But that's easily fixed by sharing the result in more functions or running the notifications and other functions in parallel inside the nested one.