· 9 min read

The trap of parallel Server Actions in Next.js

Two Server Actions queued one after another

I was chasing down a "this payment page is terribly slow" report on a Next.js App Router app. The page is an embedded checkout: it lives in an iframe, gets an auth token from its host, creates a payment session, then mounts a payment SDK.

Somewhere in the middle of that flow, two Server Actions fire back to back the moment the token arrives:

// when the token is sent from the host
saveTokens(idToken, accessToken)        // writes 2 httpOnly cookies
createPaymentSession(amount, idToken)   // POSTs to the wallet API, returns the session

Two independent calls. One writes cookies, the other hits an API. In my head they fire in parallel and we move on. That assumption cost me a morning of debugging !

Measuring it

I patched fetch on the page to log every Server Action POST (they carry a Next-Action header) with its timing:

const orig = window.fetch
window.fetch = function (input, init) {
  const na = new Headers(init?.headers).get('next-action')
  if (na) console.log('action', na.slice(0, 8), 'at', performance.now())
  return orig.apply(this, arguments)
}

Here's what I expected: two requests starting at roughly the same time. Here's what I got:

Actiondurationstarts at
saveTokens (1st)2720 mst = 0
createPaymentSession (2nd)383 mst = 2720 ms

The second action started exactly when the first one finished. Every single time. They weren't running in parallel at all — they were standing in a queue.

Plot twist: parallel Server Actions don't exist

This is in the docs, in a sentence I had skated past a hundred times. From the backend-for-frontend guide, under Caveats → Server Actions:

Server Actions are queued. Using them for data fetching introduces sequential execution.

That's it. Server Actions are not little RPC endpoints you fire and forget. They go through a queue, one at a time. So createPaymentSession — the call that actually gates the payment SDK — was sitting behind a cookie write for no reason.

Promise.all([saveTokens(), createPaymentSession()]) wouldn't have saved me either. The queue is at the framework level, not in my code.

The mutating-data docs are even blunter about it:

The client currently dispatches and awaits them one at a time. [...] If you need parallel data fetching, use data fetching in Server Components, or perform parallel work inside a single Server Function or Route Handler.

In fairness, they label this "an implementation detail [that] may change" — so don't write code that depends on the ordering. But today, in production, it's a queue, and your latency budget pays for it.

The second mystery: why does writing 2 cookies take 2.7 seconds?

Look at that table again. saveTokens only does this:

'use server'
export async function saveTokens(idToken: string, accessToken: string) {
  const c = await cookies()
  c.set('id_token', idToken)
  c.set('access_token', accessToken)
}

No network. No DB. Two cookie writes. It should be sub-millisecond. It took 2720 ms on a cold start, and bounced between 400 ms and 1800 ms when warm, for setting two cookies... 🤨

The duration also varied wildly for identical work, which made me guess that the cost wasn't in the code itself but rather in the environment around it.

It's documented too (I should read the docs more often 😅), in Mutating Data → Cookies:

When you set or delete a cookie in a Server Action, Next.js re-renders the current page and its layouts on the server so the UI reflects the new cookie value.

There it is. The cookies().set() call doesn't just set a cookie — it triggers a full server re-render of the page and every layout above it, so the UI can reflect the new cookie state. Even if you're changing a cookie that's not used in the UI 💀

And my root layout was the worst possible thing to re-render: it was dynamic and heavy. It pulls translations (getMessages()), and it's wrapped in a deep stack of providers : i18n, theme, cookies provider, recaptcha, a few context providers. One of those providers calls cookies() itself, which had already opted the whole route into dynamic rendering (the dynamic-rendering footgun I wrote about before is the same family of problem). Double fck!

So my innocent "save two cookies" action was secretly:

  1. queued ahead of the call that mattered, and
  2. re-rendering my entire app shell on the server, on a cold serverless function.

2.7 seconds setting two cookies while the payment SDK waited ! 😭

A subtlety worth naming, because I got it wrong at first: it's the write that triggers the re-render, not a read. cookies().get() re-renders nothing. The expensive part is the combination — the set() pulls the trigger, and the dynamic layout makes the resulting re-render expensive. createPaymentSession, which sets no cookie, never paid this tax.

Why it's a trap

I had a mental model: Server Action = a more ergonomic API route. Same idea, nicer call site, skip the fetch. Drop-in replacement.

It is not.

A Route Handler is a plain HTTP endpoint. It runs when you hit it, returns what you return, and that's the end of the story. A Server Action is wired into React Server Components: it goes through a queue, and (for mutations like setting a cookie or calling revalidatePath) it re-renders your route tree and ships a fresh RSC payload back. That's a feature when you're mutating data and want the UI to reflect it. It's pure overhead when you're just trying to fetch a payment session and you happen to also be persisting a cookie next door.

The Next docs say the quiet part out loud, in that same caveats section: Server Actions are for mutating data from the client. Use them for data fetching and you inherit the queue.

The fix

Moved the createPaymentSession to a Route Handler:

// app/api/payin/session/route.ts
export async function POST(req: Request) {
  // validate the origin first — this carries a bearer token
  const { amount, idToken } = await req.json()
  const session = await createSessionOnWallet(amount, idToken)
  return Response.json(session)
}
// on the page, instead of calling the Server Action:
const session = await fetch('/api/payin/session', {
  method: 'POST',
  body: JSON.stringify({ amount }),
}).then((r) => r.json())

Now createPaymentSession:

  • is not in the Server Action queue, so it fires immediately instead of waiting behind the cookie write,
  • returns a plain JSON response instead of a re-rendered RSC tree,
  • and runs in parallel with saveTokens (which can stay a Server Action, doing its layout re-render off in the background where nobody's waiting on it).

saveTokens doesn't even need to block anything — createPaymentSession already receives the token as an argument; it never needed the cookie that saveTokens was busy persisting.

Honestly, I'm not sure I'll stop there. Leaving saveTokens as a Server Action still bugs me: even shoved into the background, every cookie write re-renders my whole app shell on the server — a full render of that heavy dynamic layout, burning a serverless invocation, just to reflect a cookie nothing in the UI even reads. And "in the background" is reassuring right up until that re-render fires while the payment SDK is mounting and races with it. A pointless full-shell re-render in the middle of a payment flow is exactly the kind of thing that comes back to bite you. 😬

So I'm leaning toward moving saveTokens to a Route Handler too. A Route Handler can set the exact same httpOnly cookies — the client can't, that's the whole point of httpOnly, so it's the server's job either way — but it isn't wired into the RSC pipeline: it sets the cookies and stops. No queue, no re-render. Same two cookies, none of the tax.

Bonus: which action is which?

Server Actions show up in the network tab as POSTs to the current URL with an opaque Next-Action: 603ef47e… header. To map that hash back to a function name, grep the client bundle — Next leaves it right there in the createServerReference call:

createServerReference("603ef47e9dd7…", n.callServer, void 0, n.findSourceMapURL, "saveTokens")
//                                                                                  ^^^^^^^^^^^^

That last argument is the function name. Handy when you're staring at two identical-looking POSTs trying to figure out which one is eating your three seconds.

Conclusion

I thought Server Actions were a drop-in replacement for API routes. Reader, they are not. They're a mutation primitive bolted onto the RSC render pipeline, with a queue and a re-render attached — and both of those are invisible until you measure.

Server Actions are great — just know that "set a cookie" quietly means "re-render my whole layout," and that two of them will never, ever run at the same time.

I got f*cked by a morning of this. You don't have to 😅