DEV Community

Nainik Mehta
Nainik Mehta

Posted on

WebSockets in React Server Components: Client Islands

Why you shouldn’t copy server state into a global store for realtime

Hot take: when React Server Components (RSC) are your default, the worst thing you can do for realtime features is turn an entire page into a client bundle. Copying server state into a global client store via a page-level "use client" wrapper hands away server-rendered HTML, streaming, and tiny JS budgets — and raises Interaction to Next Paint (INP).

The alternative is simple: ship pages as Server Components and isolate WebSockets in minimal client islands that push updates into a cache (TanStack Query, SWR, or a custom store). That keeps most of the UI zero-JS, reduces re-renders, and preserves the streaming benefits RSC gives you.

The pattern at a glance

Before:

  • Page is wrapped with "use client".
  • WebSocket runs at the top level, writes to a global store.
  • Result: large client bundle, broad re-renders, worse INP.

After:

  • Page is a Server Component that fetches and renders HTML on the server.
  • Add a tiny client island for the WebSocket subscription (5–20 lines).
  • That island writes into your client cache (e.g., TanStack Query) using invalidateQueries or setQueryData.
  • Local components rehydrate only what changed.

Minimal client island example

Here's the 5-line island pattern everyone builds toward. It opens a WebSocket and updates TanStack Query — no UI, no global singleton.

// app/components/RealtimeSubscriber.client.tsx
'use client'
import { useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'

export default function RealtimeSubscriber({ url }: { url: string }) {
  const queryClient = useQueryClient()

  useEffect(() => {
    const ws = new WebSocket(url)
    ws.onmessage = (evt) => {
      // Either invalidate or apply a surgical update
      // queryClient.invalidateQueries(['feed'])
      // or: queryClient.setQueryData(['feed'], old => merge(old, JSON.parse(evt.data)))
      queryClient.invalidateQueries(['feed'])
    }
    return () => ws.close()
  }, [url, queryClient])

  return null // no UI — subscription only
}
Enter fullscreen mode Exit fullscreen mode

Drop this component into a Server Component page wherever the subscription is needed. The Server Component renders the initial HTML (and can prefetch the feed into the query cache on the server), and the island keeps the realtime pipe open after hydration.

Why this improves INP and overall performance

  • Ship far less JS: a page-level "use client" pulls every import into the client bundle. Small islands limit what actually ships.
  • Fewer unnecessary re-renders: update only the components that read the cached query rather than forcing a full client tree to re-render on every websocket message.
  • Faster first interactions: the main thread is less occupied during hydration because there’s less client JS to execute and hydrate, improving INP.
  • Keep server benefits: server-rendered HTML and streaming still work because the tree remains server-first.

Practical cache strategies (real trade-offs)

Pros:

  • Much smaller shipped JS and more predictable rendering behavior.
  • Fewer global-state edge cases — local queries own their data.
  • Measurable INP wins in audits.

Cons:

  • You need a solid cache strategy (TanStack Query or SWR). That means thinking about staleTime, invalidation, and optimistic updates.
  • Debugging is different: state is distributed between server renders and client caches.
  • Passing large datasets across the RSC boundary serializes payloads — don’t send multi-megabyte objects into islands.

Best practices:

  • Prefetch initial data on the server and hydrate the client cache using TanStack Query’s dehydrate + HydrationBoundary so client components mount with instant data.
  • Use setQueryData for surgical updates when the WebSocket sends only small deltas; fall back to invalidateQueries for convergence when you can’t easily merge.
  • Set sensible staleTime and use hierarchical query keys so invalidation is surgical (e.g., ['orders', orderId] vs ['orders']).

Implementation steps

  1. Convert the page and large UI to Server Components. Keep anything that needs effects or browser APIs as client leaves.
  2. Prefetch critical queries on the server (QueryClient.prefetchQuery + dehydrate) so the client mounts without a loading state.
  3. Add a tiny RealtimeSubscriber.client.tsx island to open the socket and update the cache.
  4. In local components, consume the query with useQuery and render the hydrated data.
  5. Monitor bundle size, hydration time, and INP with Lighthouse/WebPageTest.

Which realtime features to isolate first

Good candidates for a 5-line client island:

  • Notifications and toast events
  • Presence (online/offline indicators)
  • Small activity feeds or counters
  • Live ticks that send small deltas (metrics, counts)

Avoid sending entire datasets through the socket into the client when you can patch small diffs and let the client request larger pages when needed.

Final notes

React Server Components with WebSockets is not an either/or choice — it's a composition pattern. Use RSC for rendering, and small client islands for the connective tissue that keeps an app realtime. The result: smaller bundles, fewer global-state headaches, and snappier first interactions. If you’re modernizing a dashboard, start by isolating notifications or presence into a tiny island and measure the impact.

What realtime piece of your app would you isolate into a 5-line client island first?

Top comments (0)