Server Actions Under Load: The Serialization Bill Nobody Budgets For
Server Actions look free until traffic shows up. Here's what happens when your form submissions start serializing megabytes of closure state — and how to keep the wheels on.
Server Actions are the most quietly load-bearing feature in the App Router. They feel like magic on a demo — a form, a function, no API route — until the day a marketing push triples your traffic and your action p95 goes from 180ms to 2.4s with nothing obvious in the flamegraph. We've now debugged this on three separate client projects, and the culprit is almost always the same: serialization.
What a Server Action actually costs
When you write 'use server', you're not just marking a function. You're telling the bundler to create a stable action ID, generate a client-side reference, and set up a POST endpoint that speaks React's wire format. Every invocation sends a serialized payload up and a serialized RSC payload back down.
The request side is usually fine. The response is where budgets quietly bleed. If your action returns a value — or, more subtly, revalidates a path — Next.js will stream back a fresh RSC payload for the current route so the UI reconciles without a client-side refetch. That payload contains every server component on the page.
So a tiny likePost() action on a page with a dense feed doesn't cost you the like write. It costs you the like write plus re-rendering and serializing the entire feed to the wire, gzipped, over the network, parsed on the client.
The mental model that helps
Think of every Server Action as three separate costs:
- The mutation itself (DB write, external API, whatever).
- The revalidation fan-out (which routes/tags get invalidated, and therefore re-rendered).
- The RSC round-trip (serializing the new tree, streaming it back, reconciling).
Most teams instrument (1) and forget (2) and (3) exist. Under low traffic that's fine. Under load, (3) dominates.
The war story
One of our e-commerce clients shipped a wishlist feature during a Black Friday warmup. The action was textbook:
'use server'
import { revalidatePath } from 'next/cache'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function toggleWishlist(productId: string) {
const session = await auth()
if (!session) throw new Error('Unauthorized')
await db.wishlist.toggle({
userId: session.user.id,
productId,
})
revalidatePath('/products/[slug]', 'page')
}
Looks harmless. In staging with 50 concurrent users, p95 was 210ms. In production at peak — around 8k RPS across the fleet — p95 for the action hit 3.1s and the origin started throwing 503s from queue saturation.
The product detail page had a "You might also like" grid that fetched 24 related items with fresh inventory data. revalidatePath was invalidating that page, which meant every wishlist toggle triggered a full RSC re-render of the PDP, including the 24 related-product fetches. Each toggle cost roughly what a cold page render cost.
Multiply by traffic. The wishlist button was, effectively, a DDoS button pointed at our own origin.
The fix wasn't the obvious one
Our first instinct was to switch to revalidateTag with narrower tags. That helped a little, but the RSC payload still had to be regenerated because the page itself was in the invalidated set. The real fix was recognising that the wishlist toggle didn't need to revalidate anything server-side. The only UI that changed was the heart icon, and that state lived in the user's session.
We moved the toggle to an optimistic client update backed by a Server Action that returned nothing and revalidated nothing:
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export async function toggleWishlist(productId: string): Promise<void> {
const session = await auth()
if (!session) throw new Error('Unauthorized')
await db.wishlist.toggle({
userId: session.user.id,
productId,
})
// No revalidate. The client already knows.
}
On the client:
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleWishlist } from './actions'
export function WishlistButton({
productId,
initial,
}: {
productId: string
initial: boolean
}) {
const [isPending, startTransition] = useTransition()
const [optimistic, setOptimistic] = useOptimistic(initial)
return (
<button
aria-pressed={optimistic}
disabled={isPending}
onClick={() =>
startTransition(async () => {
setOptimistic(!optimistic)
await toggleWishlist(productId)
})
}
>
{optimistic ? 'Saved' : 'Save'}
</button>
)
}
p95 for the action dropped to 90ms. Origin CPU on the PDP fleet fell by roughly a third at peak. Nothing about the mutation changed — we just stopped paying for a page re-render we didn't need.
When to actually revalidate
The heuristic we now use on client projects:
- Don't revalidate if the UI change is local, user-specific, and the source of truth for that piece of UI is already on the client after the action.
- Revalidate a tag if the mutation changes data other users can see, and that data is cached with a matching tag.
- Revalidate a path only if the layout of the page changes, not just data inside it. Adding a comment? Tag the comments list. Publishing a new page? Sure, revalidate.
- Never revalidate broadly (
revalidatePath('/', 'layout')) from a hot action. That's a nuke on every keystroke.
We wrote about the tagging side of this in our cache tags breakdown — the short version is that granularity is the whole game.
The other hidden cost: closed-over state
Server Actions defined inside server components can close over props. That's a genuinely lovely ergonomic:
export default async function Page({ params }: { params: { id: string } }) {
async function updateTitle(formData: FormData) {
'use server'
await db.post.update({
where: { id: params.id },
data: { title: formData.get('title') as string },
})
}
return <TitleForm action={updateTitle} />
}
What actually happens: params.id gets serialized into the encrypted action reference sent to the client. React encrypts it because it's server-only state that must not be tampered with. That encryption cost is small per action, but every closed-over value adds to the encrypted blob and to the request payload when the action fires.
We've seen teams close over entire database result sets — a fetched post object, a user record — just to save a lookup on the server side. The action then ships a fat encrypted payload to the client on every render, and on submit that payload comes back up the wire. Close over IDs, not objects.
A quick audit trick
Open DevTools, submit a form, look at the request body of the POST. If it's more than a couple of KB and you didn't upload a file, something is being closed over that shouldn't be.
Concurrency and the single-flight problem
Server Actions run sequentially per-form by default — React serializes them to avoid interleaving. That's usually what you want. It becomes a problem when a page has several independent actions firing from a shared parent transition, because they queue behind each other.
If you have a settings panel with five toggles and users flip three of them quickly, the third toggle waits for the first two to finish revalidating. Under load with slow revalidation, that queue is visible as jank.
Two mitigations we use:
- Give each independent control its own
useTransitionscope so React doesn't merge them. - Keep the actions themselves revalidation-free where possible (see the wishlist pattern above), so queueing is cheap even when it happens.
Instrumentation that catches this early
Most APM tools show you the action duration but not the revalidation cost, because the revalidation happens inside the same request. What we've started doing on new projects:
import { revalidateTag } from 'next/cache'
import { performance } from 'node:perf_hooks'
export async function measuredRevalidate(tag: string) {
const start = performance.now()
revalidateTag(tag)
const duration = performance.now() - start
if (duration > 50) {
console.warn(`[action] revalidateTag(${tag}) took ${duration}ms`)
}
}
The revalidation call itself is cheap — it's just marking cache entries dirty. The real cost lands on the next render, which is harder to attribute. A tag-level render budget in your dashboards ("average render time for pages carrying tag X") is worth building before you need it.
Where we'd start
If you're inheriting a Next.js codebase with Server Actions and don't know where the risk is, do this in order:
- Grep for
revalidatePathandrevalidateTag. For each one, ask whether the UI change is actually server-owned or could be optimistic. - Open the network tab, submit each form, and check response sizes. Anything over ~30KB on a mutation is worth a second look.
- Audit closed-over variables in server-defined actions. Replace object closures with ID closures.
- Add a synthetic load test — even 200 concurrent users against a staging environment — before you trust p95 numbers from a quiet staging box.
Server Actions are still the right default for most mutations in App Router apps. They just aren't free, and the bill comes due at the exact moment you can least afford it. Budget for the serialization, not just the mutation, and the pattern holds up beautifully. If you want a hand pressure-testing yours, that's the kind of thing our team does on performance engagements.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Route Handlers vs Server Actions for Mutations: When Each One Actually Wins
Server Actions look like the obvious choice for every mutation in the App Router. They aren't. Here's how we decide between actions and route handlers on real projects, with the failure modes we've hit.
Cache Tags in the App Router: The Invalidation Story That Finally Works
Cache tags in Next.js finally give us a real invalidation model, but they punish sloppy tagging with stale data and surprise revalidations. Here's how we tag, invalidate, and debug them in production.
Streaming SSR and Suspense Boundaries: Where to Draw the Line
Streaming SSR is not a free win. Put Suspense in the wrong place and you'll ship a page that feels slower than the blocking version. Here's how we decide where the boundaries go.
