Streaming SSR

Streaming SSR — механизм потоковой серверной отрисовки, при котором HTML передаётся клиенту частями по мере готовности данных. В связке с TanStack Query это позволяет:

  • уменьшить Time To First Byte;
  • ускорить отображение интерфейса;
  • избежать блокировки всей страницы ожиданием медленных запросов;
  • постепенно гидратировать кеш;
  • комбинировать SSR, Suspense и React Server Components.

Классический SSR работает по принципу:

  1. Сервер ожидает завершения всех запросов.
  2. Формирует HTML.
  3. Отправляет страницу целиком.

Streaming SSR меняет модель:

  1. Сервер начинает отправлять HTML сразу.
  2. Отдельные части интерфейса догружаются асинхронно.
  3. Suspense-границы управляют потоками.
  4. TanStack Query синхронизирует кеш между сервером и клиентом.

Проблемы традиционного SSR

Обычный SSR в React имеет несколько фундаментальных ограничений.

Блокировка рендера

Если один запрос выполняется 2 секунды, весь HTML задерживается.

header -> 5ms
sidebar -> 20ms
profile -> 2s
comments -> 3s

Итог: пользователь ждёт 3 секунды весь HTML.

Waterfall-запросы

Компоненты могут зависеть друг от друга:

Page
 └── User
      └── Posts
           └── Comments

Каждый запрос запускается после предыдущего.

Медленная гидратация

До завершения загрузки JS страница остаётся частично неинтерактивной.

Избыточная сериализация

Большие объёмы dehydrated state увеличивают размер HTML.


Streaming SSR в React 18

React 18 представил потоковую отрисовку через:

renderToPipeableStream

или в edge/runtime средах:

renderToReadableStream

Главная идея — разбить интерфейс на Suspense-границы.

Пример:

<Suspense fallback={<PostsSkeleton />}>
  <Posts />
</Suspense>

Если Posts ещё загружается:

  • React отправит остальной HTML;
  • fallback отрендерится сразу;
  • готовый контент придёт позже отдельным chunk.

Роль TanStack Query в Streaming SSR

TanStack Query решает несколько задач:

  • серверный prefetch;
  • кеширование запросов;
  • сериализация данных;
  • восстановление состояния на клиенте;
  • предотвращение повторных запросов;
  • синхронизация Suspense и SSR.

Без TanStack Query потоковый SSR быстро превращается в хаотичный набор запросов и ручной логики.


Suspense-режим

Streaming SSR наиболее эффективно работает с Suspense.

Для этого используется:

useSuspenseQuery()

или:

useQuery({
  suspense: true
})

Пример:

function UserProfile() {
  const { data } = useSuspenseQuery({
    queryKey: ['user'],
    queryFn: fetchUser
  })

  return <div>{data.name}</div>
}

Если запрос ещё не завершён:

  • компонент приостанавливается;
  • управление переходит Suspense boundary;
  • React продолжает потоковую отрисовку.

Базовая архитектура Streaming SSR

Типичная схема:

Server
 ├── QueryClient
 ├── Prefetch critical queries
 ├── renderToPipeableStream()
 ├── dehydrate()
 └── stream chunks

Client
 ├── hydrateRoot()
 ├── HydrationBoundary
 ├── hydrate(queryCache)
 └── interactive UI

Настройка QueryClient для SSR

На сервере QueryClient создаётся отдельно для каждого запроса.

import { QueryClient } from '@tanstack/react-query'

export function createQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000
      }
    }
  })
}

Нельзя переиспользовать один QueryClient между запросами пользователей.

Это приводит к:

  • утечкам памяти;
  • смешиванию кеша;
  • передаче чужих данных.

Серверный prefetch

Критически важные запросы обычно загружаются заранее.

await queryClient.prefetchQuery({
  queryKey: ['user'],
  queryFn: fetchUser
})

После этого данные попадают в кеш сервера.


Dehydrate состояния

Сервер сериализует кеш:

import { dehydrate } from '@tanstack/react-query'

const dehydratedState = dehydrate(queryClient)

Это состояние передаётся клиенту.


Гидратация на клиенте

Клиент восстанавливает кеш:

<HydrationBoundary state={dehydratedState}>
  <App />
</HydrationBoundary>

После гидратации:

  • повторный fetch не выполняется;
  • клиент использует серверный кеш;
  • интерфейс становится интерактивным.

Streaming SSR и Suspense Boundaries

Главный принцип — разделение интерфейса на независимые потоки.

Пример:

<>
  <Header />

  <Suspense fallback={<ProfileSkeleton />}>
    <Profile />
  </Suspense>

  <Suspense fallback={<FeedSkeleton />}>
    <Feed />
  </Suspense>

  <Suspense fallback={<CommentsSkeleton />}>
    <Comments />
  </Suspense>
</>

Каждый блок рендерится независимо.


Как TanStack Query работает внутри Suspense

При вызове:

useSuspenseQuery()

TanStack Query:

  1. проверяет кеш;
  2. если данных нет — запускает queryFn;
  3. выбрасывает Promise;
  4. React перехватывает Promise;
  5. Suspense показывает fallback;
  6. после завершения Promise React продолжает рендер.

Это фундаментальный механизм Streaming SSR.


Потоковая передача HTML

Во время стриминга React формирует HTML чанками.

Упрощённо:

chunk #1:
<header>...</header>
<sidebar>...</sidebar>

chunk #2:
<profile>...</profile>

chunk #3:
<comments>...</comments>

Пользователь начинает видеть страницу почти мгновенно.


React 18 renderToPipeableStream

Node.js SSR:

import { renderToPipeableStream } from 'react-dom/server'

Пример:

const stream = renderToPipeableStream(
  <App />,
  {
    onShellReady() {
      response.statusCode = 200
      stream.pipe(response)
    }
  }
)

onShellReady вызывается, когда готов базовый HTML shell.


Edge Streaming

Для edge runtime используется:

renderToReadableStream()

Например:

  • Cloudflare Workers;
  • Vercel Edge Functions;
  • Deno Deploy.

Streaming SSR в Next.js App Router

В App Router стриминг встроен по умолчанию.

Структура:

app/
 ├── page.tsx
 ├── loading.tsx
 └── layout.tsx

loading.tsx автоматически становится Suspense fallback.


TanStack Query в Next.js App Router

Часто используется схема:

Server Components
 └── HydrationBoundary
      └── Client Components
           └── useSuspenseQuery()

Пример Streaming SSR в Next.js

Серверный компонент

import { HydrationBoundary, dehydrate } from '@tanstack/react-query'
import { getQueryClient } from './getQueryClient'

export default async function Page() {
  const queryClient = getQueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}

Клиентский компонент

'use client'

import { useSuspenseQuery } from '@tanstack/react-query'

export function Posts() {
  const { data } = useSuspenseQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts
  })

  return (
    <ul>
      {data.map(post => (
        <li key={post.id}>
          {post.title}
        </li>
      ))}
    </ul>
  )
}

Partial Hydration

Streaming SSR тесно связан с partial hydration.

Гидратируется не вся страница одновременно, а отдельные сегменты.

Преимущества:

  • меньше CPU-нагрузка;
  • быстрее интерактивность;
  • меньше main-thread blocking.

Progressive Rendering

Интерфейс отображается постепенно:

1. Header
2. Navigation
3. User profile
4. Feed
5. Comments
6. Recommendations

Пользователь получает usable UI быстрее.


Использование loading.tsx

В Next.js:

app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />
}

Пока сервер загружает данные:

  • React stream отправляет skeleton;
  • после готовности UI заменяется автоматически.

Skeleton UI и Streaming SSR

Skeleton особенно важен для стриминга.

Плохой fallback:

<Suspense fallback={<div>Loading...</div>}>

Хороший fallback:

<Suspense fallback={<PostsSkeleton />}>

Причины:

  • уменьшается layout shift;
  • сохраняется структура страницы;
  • интерфейс выглядит стабильнее.

Error Boundary

Suspense не обрабатывает ошибки.

Нужен Error Boundary:

<ErrorBoundary fallback={<ErrorScreen />}>
  <Suspense fallback={<Loader />}>
    <Posts />
  </Suspense>
</ErrorBoundary>

React Query Error Reset Boundary

TanStack Query предоставляет:

<QueryErrorResetBoundary>

Пример:

<QueryErrorResetBoundary>
  {({ reset }) => (
    <ErrorBoundary onRe set={reset}>
      <Suspense fallback={<Loader />}>
        <Posts />
      </Suspense>
    </ErrorBoundary>
  )}
</QueryErrorResetBoundary>

Предотвращение двойных запросов

Неправильная SSR-конфигурация приводит к:

  • fetch на сервере;
  • повторному fetch на клиенте.

Правильная гидратация предотвращает это.

Ключевые условия:

staleTime > 0

и корректный:

dehydrate()

staleTime в Streaming SSR

Для SSR почти всегда нужен положительный staleTime.

Плохо:

staleTime: 0

Клиент немедленно перезапросит данные.

Лучше:

staleTime: 60_000

gcTime

В SSR важно контролировать память.

gcTime: 5 * 60 * 1000

Большие значения:

  • увеличивают потребление RAM;
  • могут перегружать сервер.

Серверные утечки памяти

Типичная ошибка:

const queryClient = new QueryClient()

на уровне модуля.

Правильно:

export function createQueryClient() {
  return new QueryClient()
}

для каждого request.


Prefetch только критических данных

Streaming SSR не требует prefetch всех запросов.

Обычно prefetch делают только для:

  • hero section;
  • navigation;
  • user session;
  • critical SEO content.

Второстепенные данные могут стримиться позже.


Lazy Streaming

Некоторые запросы можно вообще не загружать на сервере.

Например:

- recommendations
- analytics
- notifications
- chat widgets

Такие блоки выгоднее оставить клиентскими.


Разделение критических и некритических запросов

Хорошая архитектура:

Critical:
 ├── auth
 ├── product
 ├── seo
 └── navigation

Non-critical:
 ├── comments
 ├── recommendations
 ├── ads
 └── analytics

Nested Suspense

Streaming SSR поддерживает вложенные границы.

<Suspense fallback={<PageSkeleton />}>
  <Page>
    <Suspense fallback={<CommentsSkeleton />}>
      <Comments />
    </Suspense>
  </Page>
</Suspense>

Waterfall-проблемы Suspense

Неправильная вложенность создаёт последовательные ожидания.

Плохо:

<User>
  <Posts>
    <Comments>

Лучше:

<>
  <User />
  <Posts />
  <Comments />
</>

в независимых Suspense boundaries.


Параллельные запросы

TanStack Query автоматически дедуплицирует запросы.

Можно запускать параллельно:

await Promise.all([
  queryClient.prefetchQuery(...),
  queryClient.prefetchQuery(...),
  queryClient.prefetchQuery(...)
])

Streaming и SEO

SSR остаётся SEO-friendly.

Поисковые системы получают:

  • серверный HTML;
  • метаданные;
  • контент страницы.

Streaming не ухудшает индексацию.


Time To First Byte

Streaming SSR значительно уменьшает TTFB.

Без стриминга:

TTFB = ожидание всех запросов

Со стримингом:

TTFB = готовность shell

Server Components и TanStack Query

В React Server Components:

  • часть запросов выполняется на сервере;
  • часть — через TanStack Query на клиенте.

Типичная стратегия:

RSC:
 ├── SEO
 ├── metadata
 ├── static content

Client Query:
 ├── live feed
 ├── realtime updates
 ├── mutations
 └── interactive state

Потоковая передача кеша

HydrationBoundary может использоваться многократно.

Например:

<HydrationBoundary state={stateA}>
  <SectionA />
</HydrationBoundary>

<HydrationBoundary state={stateB}>
  <SectionB />
</HydrationBoundary>

Это позволяет стримить части кеша независимо.


Dehydrate и размер HTML

Большой dehydrated state увеличивает HTML.

Оптимизация:

dehydrate(queryClient, {
  shouldDehydrateQuery: query =>
    query.meta?.ssr === true
})

Исключение ненужных запросов

Не стоит сериализовать:

  • временные данные;
  • pagination cache;
  • infinite queries целиком;
  • analytics;
  • ephemeral state.

Infinite Queries в Streaming SSR

Infinite queries могут создавать огромный dehydrated payload.

Проблема:

20 pages × 50 items = massive HTML

Оптимизация:

initialPageParam

и серверная загрузка только первой страницы.


AbortSignal в SSR

Если пользователь прервал соединение:

  • запросы должны отменяться;
  • ресурсы должны освобождаться.

TanStack Query поддерживает AbortSignal:

queryFn: async ({ signal }) => {
  const response = await fetch(url, { signal })
  return response.json()
}

Retry в SSR

На сервере retry обычно отключают.

queries: {
  retry: false
}

Причины:

  • SSR должен быть быстрым;
  • повторные запросы замедляют stream;
  • ошибки лучше обработать fallback UI.

Streaming и Edge Cache

Streaming SSR хорошо сочетается с CDN.

Можно кешировать:

  • shell;
  • partial chunks;
  • RSC payload;
  • HTML stream.

React Flight

В Next.js App Router Streaming SSR тесно связан с React Flight protocol.

Передаются:

  • server component payload;
  • streamed chunks;
  • client boundaries;
  • serialized cache.

DevTools и Streaming SSR

TanStack Query Devtools помогают анализировать:

  • hydration;
  • повторные fetch;
  • cache hits;
  • stale state;
  • waterfall queries.

Диагностика повторных запросов

Типичные причины:

- staleTime = 0
- hydrate mismatch
- queryKey mismatch
- разный queryFn
- сериализация undefined

QueryKey и SSR

QueryKey должен быть полностью идентичным:

Сервер:

['posts', page]

Клиент:

['posts', page]

Любое отличие ломает гидратацию.


Сериализуемость данных

dehydrate сериализует JSON-compatible данные.

Проблемы вызывают:

  • Date;
  • Map;
  • Set;
  • class instances;
  • functions.

Нормализация данных

Перед SSR полезно преобразовывать:

return {
  createdAt: date.toISOString()
}

Производительность Streaming SSR

Наибольший выигрыш наблюдается при:

  • медленных API;
  • больших страницах;
  • сложных dashboard;
  • social feed;
  • ecommerce;
  • news platforms.

Когда Streaming SSR избыточен

Механизм может быть неоправдан при:

  • маленьких SPA;
  • полностью статических сайтах;
  • простых landing pages;
  • интерфейсах без тяжёлого SSR.

Streaming SSR и realtime

Streaming SSR особенно эффективен вместе с realtime-обновлениями.

Схема:

1. Initial HTML stream
2. Hydration
3. WebSocket updates
4. Query cache updates

Архитектурная стратегия

Оптимальная стратегия для современных React-приложений:

Server:
 ├── critical SSR
 ├── stream shell
 ├── prefetch SEO data
 └── partial hydration

Client:
 ├── TanStack Query
 ├── realtime sync
 ├── mutations
 └── background refetch

Практическая схема Suspense-архитектуры

Page
 ├── Header
 ├── Navigation
 ├── Suspense(User)
 ├── Suspense(Feed)
 ├── Suspense(Comments)
 ├── Suspense(Recommendations)
 └── Footer

Каждый блок:

  • независим;
  • потоково рендерится;
  • отдельно гидратируется;
  • имеет собственный fallback;
  • не блокирует остальной интерфейс.