Skip to main content

Command Palette

Search for a command to run...

Decoupling WebSockets from React Server Components

Why router.refresh() kills your performance at scale, and the Snapshot + Delta pattern to fix it.

Updated
•8 min read•View as Markdown
Decoupling WebSockets from React Server Components
V
I am a learner and an explorer sharing my journey here on Hashnode. Please reach out if you like my work let's have a chat about it

Confession time : When React Server Component landed in the Next.js , I was hooked instantly.

Being able to perform queries on the database directly from the async component , without client-side fetch slowdown , and with no javaScript at all for static content seemed like having a superpower . And the regular CRUD dashboard , it was almost cheating.

And so the product come with the expected request:

"Hey , can we have this activity feed update be real-time?"

Naturally, the developer Brain kicks in : we have server components on the server . webSocket work the on the server as well . Can't we connect them ?

If you've attempted that, you know where this is going : you get hydration problem , hanging serverless function, and broken rendering of the ui.

Connecting WebSockets and React Server Components is like connecting a garden hose to a usb port. They simply don't fit each other mentally.

This is why trying to connnect them breaks your achitecture, what really happens behind the scenes when you do that , and how you can neatly decouple them so your app stays fast , scalable, and sane to maintain.

The Architecture Conflict : Ephemeral Vs Persistent

To understand what is wrong with these combination, consider how each of the technologies really works:

A React Server Component is ephemeral. An RSC is run once per request on the server , packed into a stream(RSC payload), deliverd to the browser , and the disposed. The lifecycle of an RSC is transactional.

A webSocket connection is persistent. It's a stateful, long-lived , bidirectional TCP tunnel opened for seconds , minutes, or even for hours.

whenever developer attempts to combine those two technologies, they always bump into three fundamental arhitectural obstacles:

1.The Serverless Execution Trap:

Most modern Next.js applications will be running on serverless platforms(vercel, AWS). Functions built on the top of the serverless model are supposed to start up , process a request, and either pause or close down.

should you attempt to maintain a permanent WebSocket connection on your serverless Next.js setup , you'll have to go against the natural order of things : Lambda time-outs , cold starts terminating active WebSockets, and huge expenses with AWS /your hosting provider due to active but idle connections.

2.State and Churning State:

state in an RSC should exist in the database rather than in-memory across multiple renderings.But a webSocket will stream updates(deltas) into memory all the time,

without decoupling the two , you'll be making an attempt to force the RSC to re-render every time there's a new webSocket message. And here comes the most popular anti-pattern that you will encounter in the wild.

The Anti-Pattern: (trashing the server with the router.refresh()) This is the approach people tend to choose when they need real-time updates on app router:

 //The Anti-Pattern: Spamming server re-renders on socket events 'use client';

import { useEffect } from 'react'; import { useRouter } from 'next/navigation';

export function RealtimeFeed({ initialItems }: { initialItems: FeedItem[] }) { const router = useRouter();

useEffect(() => { const socket = new WebSocket(process.env.NEXT_PUBLIC_WS_URL!);


socket.onmessage = () => {
  router.refresh();
};

return () => socket.close();


}, [router]);

return ; }
Why this matter in practice:

router.refresh() request a completely new RSC payload from the server.

when you have 1,000 concurrent users and an event broker feeding 15 events per second, you're no longer working with lightweight Json deltas through a socket connections. Instead, you've just invoked 15,000 server-side renders per second!

your database gets hammered with queries , your server's CPU stuggles with overloads , and your users experience micro-stutters as React has to re-render the component tree all over again.

Solution: The "Snapshot + Delta" Architecture pattern the elegant way to solve the problem is to separate the baseline snapshots retrieval from real-time stream processing.

RSC serves the Baseline snapshots: use asynchronous server components as intended, fetching initial data fast and efficiently straight from the DB or the cache, with no client bundiling overhead.

Client layer process the delta stream : provide the snapshot as a seed prop inside an interactive Client Component boundry. Establish the webSocket connection exclusively on the client side(connect to a socket or a managed broker service), merge events into the state locally in memory.

RSC take your users to first contentful paint(FCP) right away, while the client socket deals with subsequent state updates with zero server re-render overhead.

Let’s implement this cleanly with Next.js App Router and TypeScript.

1. The Server Component (First Snapshot)

The page remains a server component with asynchronous capabilities. It talks to your database directly and passes on the first snapshot across the boundary:

// app/activity/page.tsx (Server Component)
import { db } from '@/lib/db';
import { LiveActivityContainer } from @/components/LiveActivityContainer;

export default async function ActivityPage() {
  // 1. Fast server-side snapshot (runs close to the database)
  const initialActivities = await db.activity.findMany({
    take: 20,
    orderBy: { createdAt: 'desc' },
  });

  return (
    <main>
      <header>
        <div>
          <h1>System Activity</h1>
          <p>Live operational telemetry feed</p>
        </div>
      </header>

      {/* 2. Hand off snapshot to the decoupled client boundary */}
      <LiveActivityContainer initialData={initialActivities} />
    </main>
  );
}

2. The Custom Hook (Socket and Delta Reconciler)

Everything related to sockets, reconnecting and state merging is done within the custom hook and does not involve any server-side actions or router revalidations.

// hooks/useLiveStream.ts

'use client';

import { useEffect, useState, useRef } from 'react';

interface StreamOptions<T> {
  initialData: T[];
  endpoint: string;
  getKey: (item: T) => string | number;
}

export function useLiveStream<T>({ initialData, endpoint, getKey }: StreamOptions<T>) {
  const [data, setData] = useState<T[]>(initialData);
  const [isConnected, setIsConnected] = useState(false);
  const socketRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    const ws = new WebSocket(endpoint);
    socketRef.current = ws;

    ws.onopen = () => setIsConnected(true);
    ws.onclose = () => setIsConnected(false);

    ws.onmessage = (event) => {
      try {
        const payload = JSON.parse(event.data);

        if (payload.type === 'INSERT') {
          setData((prev) => {
            // Deduplicate to avoid race conditions with initial fetch
            const exists = prev.some((item) => getKey(item) === getKey(payload.item));
            if (exists) return prev;
            return [payload.item, ...prev];
          });
        }

        if (payload.type === 'UPDATE') {
          setData((prev) =>
            prev.map((item) => (getKey(item) === getKey(payload.item) ? payload.item : item))
          );
        }
      } catch (err) {
        console.error('[WebSocket] Failed to parse message payload:', err);
      }
    };

    return () => {
      ws.close();
      socketRef.current = null;
    };
  }, [endpoint, getKey]);

  return { data, isConnected };
}

3. The Client Component (Pure UI & State Integration)

The container component simply consumes the hook and renders the list without any server round-trips:

// components/LiveActivityContainer.tsx
'use client';

import { useLiveStream } from '@/hooks/useLiveStream';
import type { Activity } from '@/types';

export function LiveActivityContainer({ initialData }: { initialData: Activity[] }) {
  const { data: activities, isConnected } = useLiveStream<Activity>({
    initialData,
    endpoint: process.env.NEXT_PUBLIC_WS_URL || 'wss://stream.myapi.com/events',
    getKey: (item) => item.id,
  });

  return (
    <section>
      <div>
        <span>Displaying {activities.length} records</span>
        <span>
          <span
            className={`h-2 w-2 rounded-full ${
              isConnected ? 'bg-emerald-500' : 'bg-amber-500 animate-pulse'
            }`}
          />
          {isConnected ? 'Stream Connected' : 'Reconnecting...'}
        </span>
      </div>

      <div >
        {activities.map((activity) => (
          <article key={activity.id}>
            <div>
              <p>{activity.title}</p>
              <time">
                {new Date(activity.createdAt).toLocaleTimeString()}
              </time>
            </div>
            <p>{activity.description}</p>
          </article>
        ))}
      </div>
    </section>
  );
} 

So Where Does Your WebSocket Gateway Live ?

If your Next.js application is deployed to serverless functions,where does the socket connections go?

As a software development engineer, I think about WebSocket gateway separately as an independent piece of infrastructure:

1.A separate sevice(Node.js/Go/Rust): An independent applications running in the cloud as EC2 Instance, ECS container , or fly.io application maintaining persistent connections and subscribing to redis Pub/Sub cluster.

2.Stateful edge workers: Cloudflare Worker with Durable Objects provide you with a global and stateful webSocket termination without any server maintenance hassle.

3.Managed brokers:Managed Webhooks to WebSocket tricks(such as Ably or AWS Api Gateway WebSocket Api)manage the persistent connection layer and fan out while you keep you server-side code stateless.

Our Next.js server application is clean and stateless.

The Bottom Line

Current web development isn’t one-size-fits-all. React Server Components work well with static content and initialization while WebSockets fit bi-directional updates.

They aren’t limited to the same layer and can be used together in combination without monolithic components.

Real-time implementation checklist for your RSC project:

1.Goal: storing initialization state or deltas? Use RSC for initialization and WebSocket/SSE for deltas.

2.Relying on router.refresh() for communication? Don’t do that anymore, take care of the client-side processing of deltas.

3.Is disconnection not part of your serverless application? Make sure you have a stateless Next.js app and socket layer running separately.

TL;DR:

  • The Trap: Using router.refresh() on WebSocket events forces full server re-renders, trashing database connections and CPU under high load.

  • The Fix: Decouple responsibilities. Use React Server Components for the initial fast snapshot, and handle live deltas strictly in client memory.

  • The Infrastructure: Terminate WebSockets on dedicated, stateful instances (or edge workers), keeping your Next.js serverless functions lean and stateless.

If i ask u how do you implement real-time in your RSC project? Do you use separate WebSocket servers, SSE or brokers of some kind? Let me know!

A

There is a lifecycle issue in the sample that would work against the snapshot-plus-delta design: getKey is an inline function in LiveActivityContainer, while the hook includes getKey in its effect dependencies. Each setData update rerenders the component, creates a new function and can close and reopen the socket.

I would give that key extractor a stable identity and add a test asserting that several incoming updates keep one connection open. Otherwise a healthy event stream can produce its own reconnect churn, even after removing every router.refresh call. That is separate from deduplicating the initial snapshot.

Y

Awesome breakdown. RSCs definitely felt like a magic bullet at first, but separating out the real-time layer makes total sense. Great read