Arunanshu Biswas@arunanshub
← writing
#engineering#frontend10m

Stop fetching client data with server actions. Use oRPC.

My data? Validated. My errors? Enumerated. My actions? Committed. But my fetches?

Nextjs runs all its server actions sequentially, and there’s nothing you can do to change that. For the longest time it was still my favorite way to fetch data on the client, especially when fetching on the server and passing the data (or the promise) down wasn’t easy to do. That second approach has its own headaches, which we’ll get to.

The problem

Here’s a component. And yes it’s interactive 😎

We’re using Tanstack Query v5 to fetch some data through a server action. Hit “Fetch all!” and watch closely. Are they really loading at the same time?

Fetch using server actions
Also check your devtools
What are our names, and how long did we wait to arrive here?
    The data has not loaded yet
    The data has not loaded yet
    The data has not loaded yet

Nope! Each one waits for the one before it. Open your devtools and you’ll watch the three requests go out one at a time, in a little cascade.

Here’s the server action:

'use server'

import { sleepFor } from '@/lib/sleep'

export async function getMyName({
  waitFor,
  name = 'Arun'
}: { waitFor: number; name?: string }) {
  await sleepFor(waitFor)
  return { name, waitFor }
}

and the fetching logic:

const queryResults = useQueries({
  queries: queryArgs.map((q) => ({
    queryKey: [fetchKey, q],
    queryFn: () => getMyName(q),
    enabled: false,
  })),
})

Pretty simple, isn’t it? useQueries fires them off together, and they still come back one by one. Which means my code isn’t the problem here. Nor is Tanstack Query. The problem must then be with whatever is doing the fetch requests (or atleast lining them up for firing).

But… but… useQueries fires requests concurrently! Then what gives?

So why does this happen?

I know it’s quite easy to hate on Nextjs and call it a bug or even a conspiracy by Vercel, but (un)fortunately for us, it’s none of them. Nextjs dispatches server actions one at a time on purpose, and the docs are pretty upfront about it:

Next.js dispatches Server Actions one at a time per client. If a user triggers three actions in quick succession, the second waits for the first to finish, then the third waits for the second. This keeps the re-rendered server tree consistent with the action result that produced it.

What matters here is the last bit: ”[…] the re-rendered server tree consistent […]”. A server action does more than call a function over the wire. Its response actually carries two things at once: the value you returned, and a freshly re-rendered tree for the current route.1 The action can mutate something and hand you back the new UI in a single round trip. That’s what lets <form action={...}> update the page with no follow-up fetch.

But then why does Tanstack Start afford to call server actions in parallel? Because they aren’t server actions at all in the truest sense of the word. They do NOT carry the render tree. They just carry the data. Oh and by the way, they call it server functions. We’ll discuss about them later.

Now picture three of those firing at once with each one revalidating and re-rendering the same route. Which re-render wins? There’s no good answer, and Nextjs doesn’t try to pick one either. Instead, it runs them in order: one action, one re-render, then the next. And notice the thing the docs are careful about: this is a property of the client dispatcher, not of server functions themselves.

This is a property of the client dispatcher, not of Server Functions in general. Server-side, an action runs in its own request and can do anything an async function can do.

So the sequencing is correct. For a mutation that continues to work even if JS is blocked, it’s exactly what I want. The catch is that fetching three names client side isn’t a mutation. There’s nothing to keep consistent and no render tree to reconcile, so I’m paying for a guarantee I don’t need, and the bill is two extra round trips of waiting. (Plus I’m using JS anyway so if my user doesn’t enable JS, that’s their problem, not mine 🙂)

Which is the long way of saying what React already tells you: server actions aren’t built for data fetching.

Server Functions are designed for mutations that update server-side state; they are not recommended for data fetching. Accordingly, frameworks implementing Server Functions typically process one action at a time and do not have a way cache the return value.

Typically. Not everywhere, though. Like mentioned before, Tanstack Start has no such rule. And as much as I love Tanstack Start, Vite, and the whole toolchain around it, you can’t always Just Migrate™️ to a new framework. So before we go hunting for a fix, let me be clear about what I actually want, because a fix is only worth anything if it gives me back everything the server action was already doing.

What I actually want

One thing I really love about Tanstack Start is its server functions. They validate their input against a schema, let you stack middleware, and are generally just clearer to comprehend compared to whatever use server is supposed to mean:

import { createServerFn, createMiddleware } from '@tanstack/react-start'
import * as v from 'valibot'

const loggingMiddleware = createMiddleware().server(async () => {
  //...
})

export const getMyName = createServerFn()
  .middleware([loggingMiddleware, /* and so on */])
  .validator(v.object({
    ...
  }))
  .handler(async ({data: {...}}) => {
    // This runs only on the server
    return {name: /*...*/}
  })

const name = await getMyName({...})

You can get most of that in Nextjs with next-safe-action, which turns a plain action into a validated one:

"use server";
import { actionClient } from "@/lib/safe-action";
import * as v from 'valibot';

export const getMyName = actionClient
  .inputSchema(v.object({
    // ...
  }))
  .action(async ({ parsedInput: { /* ... */ } }) => {
    return { /* ... */ }
  });

I’ve used it and I liked it. But underneath it’s still a server action, so it still goes out one call at a time. It makes my actions safe; it doesn’t make them concurrent. So here’s my wishlist:

  1. The calls to the server MUST2 run concurrently.
  2. It MUST have validation built in, like next-safe-action or Tanstack Start.
  3. It SHOULD play nicely with Tanstack Query.
  4. It SHOULD let me cancel a request mid-flight. 👀
  5. It SHOULD let me cache a result with use cache. 👉👈

A plain server action, even a safe one, gives me maybe two (#2 and #5). Let’s go find something that gives me all five.

oRPC

oRPC lets you define a procedure (an input schema, handler, typed errors) and call it from the client with full type safety over plain HTTP. That “plain HTTP” part is the whole trick as you’ll see in a second. Here’s the same demo but backed by oRPC. Fetch everything at once again:

Fetch using oRPC (also check your devtools)
What are our names, and how long did we wait to arrive here?
  • The data has not loaded yet
  • The data has not loaded yet
  • The data has not loaded yet

See the difference? This time they all show up together. Open the devtools and you’ll see three requests to /rpc going out in parallel instead of politely queueing up.

And to be fair, this isn’t oRPC being faster or doing anything clever. It’s just a transport. The server action rides through React’s dispatcher which serializes on purpose. An oRPC call is a plain HTTP request, and the browser has always been happy to run a few of those at once.

So no, I didn’t out-engineer Nextjs; I just stopped shoving a read through the mutation queue.

It’s just HTTP (requirement #1)

The route is a normal procedure. Nothing in here knows or cares about React’s action dispatcher:

import 'server-only'
import * as v from 'valibot'

import { sleepFor } from '@/lib/sleep'
import { base } from '@/server/orpc/orpc'

const routes = {
  getMyName: base
    .input(
      v.object({
        name: v.optional(v.string(), 'Arun'),
        waitFor: v.optional(v.pipe(v.number(), v.safeInteger()), 100),
      }),
    )
    .handler(async ({ input: { name, waitFor } }) => {
      await sleepFor(waitFor)
      return { name, waitFor }
    }),
}

export default routes

You mount it once as a route handler, and every procedure is reachable over POST /rpc:

import { RPCHandler } from '@orpc/server/fetch'
import { router } from '@/server/orpc/router'

const handler = new RPCHandler(router)

async function handle(request: Request) {
  const { response } = await handler.handle(request, { prefix: '/rpc' })
  return response ?? new Response('Not found', { status: 404 })
}

export const GET = handle
export const POST = handle

Validation (requirement #2)

Look at that .input(...) line again. It’s a valibot schema and oRPC runs every call through it before your handler ever sees the data. Remember the plain server action from the top of the post? It validated nothing; it destructured waitFor and hoped the data is in shape. This is the same safety next-safe-action gives you, but it’s a wrapper over server actions that I have to remember compared to a safety feature that’s baked in.

Tanstack Query (requirement #3)

oRPC also ships a Tanstack Query adapter! One call, and your whole client turns into a set of query-option factories 🚀!

export const orpcQuery = createTanstackQueryUtils(orpcClient)

const queryResults = useQueries({
  queries: queryParams.map((input) =>
    // how cool is this!
    orpcQuery.dataFetcher.getMyName.queryOptions({ input, enabled: false }),
  ),
})

Every procedure now has a .queryOptions(), a .key(), and other goodies, all typed straight from the router. (The orpcClient itself has a fun little quirk: it behaves differently on the server and in the browser. That’s a rabbit hole for another day. So here just treat it as a client.)

Cancellation (requirement #4)

Because oRPC rides on fetch, and a fetch can be aborted, I can cancel a request that’s already in flight:

await queryClient.cancelQueries({
  queryKey: orpcQuery.dataFetcher.getMyName.key(),
})

Go hit the “Cancel Queries” button on the oRPC demo above while a fetch is running, and watch the requests bail. This works because oRPC passes TanStack Query’s AbortSignal all the way down to the underlying fetch. Cancel the query, and the real HTTP request gets aborted, not just forgotten on the client.

A server action gives you nothing to pass down. There’s no AbortSignal on the call, so the most cancelQueries can do is drop the result on the client while the request keeps running to completion on the server. Real cancellation needs a transport that’s listening for it, and a server action isn’t one.

use cache (requirement #5)

To be honest I wasn’t even sure if it was going to work. Fetch once in the demo below then fetch again without hitting reset:

Fetch using oRPC, with `use cache`
Fetch once, then fetch again. Reset for a fresh cache key.
What are our names, and how long did we wait to arrive here?
  • The data has not loaded yet
  • The data has not loaded yet
  • The data has not loaded yet
Not fetched yet

The first fetch takes the full 300ms each. The second is basically instant. Hit Reset All! and the next one is slow again. That’s Nextjs’ use cache, running right inside the oRPC handler:

async function getMyNameCached({
  name,
  waitFor,
  round,
}: { name: string; waitFor: number; round: number }) {
  'use cache'
  cacheLife('minutes')
  await sleepFor(waitFor)
  return { name, waitFor, round }
}

const routes = {
  // other routes
  getMyNameCached: base
    .input(v.object({/* ... */}))
    .handler(async ({ input }) => getMyNameCached(input)),
}

What surprised me is that this call lands in a route handler, not a render, and use cache still caches it across requests. In a production build the first call took 344ms and the repeat 4.5ms.

The round argument is the little trick behind that “Reset” button. A use cache entry is keyed on the function’s arguments (name, waitFor and round, all of them), so bumping round by one gives you a brand new key, a cache miss, and your wait is back. Changing an argument is the whole cache-busting strategy.

Why oRPC and not tRPC?

Fair question. tRPC also gives you typed procedures over HTTP, and it’d fix the sequencing just as well. It works just fine! I went with oRPC for the smaller stuff: it speaks standard-schema, so my valibot schemas drop straight in; its contract-first shape fits the way I think; typed errors are a first-class concern; no React context; and in a Nextjs app that’s mostly Server Components, it doesn’t fight the soul of RSC. If you’re already on tRPC and happy, you’re just fine. The whole point is getting out of the action queue, and either one gets you there. (but I still recommend oRPC because of its simplicity and ease of use) :D

So where does that leave us?

My data’s validated. My errors are typed. My real mutations still commit one at a time exactly like they should. And what about my fetches? They’re finally just fetches again: they run in parallel, I can cancel them, I can cache them, and they stay type-safe the whole way from the schema down to the component.

And the best part is that I didn’t have to give up anything next-safe-action was already doing for me.

Footnotes

  1. https://archive.ph/b8n4l

  2. RFC 2119