Articles

The Big "Server Waterfall Problem" with RSCs

Kent C. Dodds
Kent C. Dodds

Let's analyze this React tree structure:


<App>
<ShipSearch />
<ShipDetails />
</App>

Let's assume that App renders the ShipSearch and ShipDetails components, passing props as needed.

ShipSearch is a server component which takes a search and uses that to perform a database query:


async function ShipSearch({ search }) {
const shipResults = await searchShips({ search })
// ... render stuff
}

The ShipDetails component is also a server component which takes a shipId and uses that to perform a database query:


async function ShipDetails({ shipId }) {
const ship = await getShip({ shipId })
// ... render stuff
}

Because these are sibling components, both queries will run at the same time.


searchShips query ----> searchShips result
getShip query ----> getShip result

Server Waterfalls

That's all fine. But let's say the App component needs to resolve a logged in user:


async function App() {
const user = await getLoggedInUser()
// ... render stuff
}

Uh oh, now we have a waterfall:


getLoggedInUser query ----> getLoggedInUser result
searchShips query ----> searchShips result
getShip query ----> getShip result

The issue here is now App is waiting for getLoggedInUser to resolve before it can render anything. This means both searchShips and getShip won't be executed until after getLoggedInUser resolves, even though those don't directly depend on the user.

This is a problem generally for React Suspense as well and I dive deep into that in the React Suspense workshop exercise 6.

There are a few ways to think about this problem:

Maybe it's not a problem if getLoggedInUser is fast 🤷‍♂️ But that's not always a sure thing, and even if it's fast today doesn't mean someone won't add something else that will be slow tomorrow. Still, it's always good to think about how bad ignoring the problem would actually be since there could be bigger fish to fry.

Alternatively, we can lift searchShips and getShip into App and pass the results down to ShipSearch and ShipDetails. Then we could use Promise.all to make sure they run concurrently. But passing props can get annoying pretty fast. We can use a library like @epic-web/cachified to dedupe the queries and then App simply kicks off the query earlier which would at least be better.

Unfortunately, another issue though is this can get unweildy pretty fast, especially if there's logic around which queries should run based on other props. You'd have to move all that logic until your entire app lives in App 😱.

The next solution I can come up with is to use or build a compiler which can find all queries and preload them automatically. This is what Relay is. There are quite a few things I don't like about this approach though:

  1. If you have logic around which queries should run based on other props, this approach doesn't work.
  2. It adds a lot of complexity to your build process.
  3. When you're working in product code, it's not obvious what's happening.

So I guess what I'm saying is I don't like any of these solutions. But I'd like to reframe the problem with more context.

The status quo

Let's say we live in a world where we don't have RSCs or Suspense. You're just building an app with components that fetch data and render stuff.

In that world you've got three options:

  1. Group together (colocate) your queries with the components that need them.
  2. Lift all your queries into a single component and pass the results down.
  3. Use a compiler to find all your queries and preload them automatically.

Sound familiar? It's exactly the same problem we've always had with components and data. There's always been this tension between colocating and passing down data (prop drilling).

One of the nice things about Remix is that it gives you a fourth option:

  1. Decouple your data fetching from your components.

This allows Remix to load data as soon as the request comes in regardless of whether the component has rendered.

This is what I've been doing for years and it's been awesome.

So, why RSCs?

This blog post isn't really about why I think RSCs are awesome. You can read React Server Components: The Future of UI for that. Just know that composition at route boundaries is not as good as composition at component boundaries and I want React-level composition badly.

What I want to do is show how RSCs fit into the same tension between colocating and prop drilling that we've always had with components and data. There's no new problems here.

But what I find interesting is how a server-side waterfall is probably better than a client-side waterfall primarily because you get to control the network. The connection between your server rendering server and your database is probably stronger, faster, more reliable, and closer. Or maybe it's not, but the point is you are in control there and can make improvements to that if it's important to you. You also have more fine-grained control over caching (requests from separate clients can share a cache for common data).

When you have a client-side waterfall, you're dealing with the user's device and their network connection which may be great or may be terrible but you definitely have no control over.

So shifting this problem from the client to the server sounds like a net gain for many scenarios.

Conclusion

The "waterfall" problem of RSCs is not a new problem. It's the same tension between colocating and prop drilling that we've always had with components and data. Maybe there's a new solution we can look forward to in the future. I welcome ideas (preferrably something that doesn't require a special compiler)! Until then, I'm actually pretty happy with just making things fast enough that the problem isn't a problem.

Get my free 7-part email course on React!

Delivered straight to your inbox.