Skip to content

Empty useServerInsertedHTML results still trigger a React render for each streamed chunk #99331

Description

@iamibi

Link to the code that reproduces this issue

https://github.com/iamibi/next.js/tree/repro/server-inserted-html-skip

To Reproduce

The registry is mounted only on /with-registry. Its callback returns a style on the first flush and an empty Fragment on subsequent flushes; both routes otherwise render the same delayed Suspense boundaries. The key callback is:

// app/style-registry.tsx (excerpt)
useServerInsertedHTML(() => {
  const styles = registry.styles()
  registry.flush()
  return <>{styles}</>
})

The route streams one delayed Suspense boundary per requested ?boundaries=N, which causes multiple HTML and inline Flight chunks:

// app/streaming-content.tsx (excerpt)
import { Suspense } from 'react'

async function Delayed({ ms }: { ms: number }) {
  await new Promise((resolve) => setTimeout(resolve, ms))
  return <p>Resolved after {ms}ms</p>
}

export function StreamingContent({ boundaries }: { boundaries: number }) {
  return (
    <main>
      {Array.from({ length: boundaries }, (_, index) => {
        const ms = (index + 1) * 100
        return (
          <Suspense key={ms} fallback={<p>Loading ({ms}ms)...</p>}>
            <Delayed ms={ms} />
          </Suspense>
        )
      })}
    </main>
  )
}

The public reproduction is a minimal App Router app with a CSS-in-JS style registry and a control route without one. Its instrumentation.ts loads span-counter.ts, which counts Next.js OpenTelemetry spans per request. server_inserted_html_renders is AppRender.renderToReadableStream minus the page's own render. The counter is in the linked reproduction so the output below can be obtained without changing Next.js source.

git clone --branch repro/server-inserted-html-skip --single-branch https://github.com/iamibi/next.js.git
cd next.js
npm install
npx next info
npx next build
NEXT_OTEL_VERBOSE=1 npx next start
# In a second terminal:
curl -s -o /dev/null 'http://localhost:3000/without-registry?boundaries=5'
curl -s -o /dev/null 'http://localhost:3000/with-registry?boundaries=5'
curl -s -o /dev/null 'http://localhost:3000/with-registry?boundaries=20'

Fresh next start output on next@16.4.0-canary.50 (Turbopack), captured on 2026-09-27; each line is printed when its response finishes streaming:

[span-count] target=/without-registry?boundaries=5 fizz_renders=2 page_renders=1 server_inserted_html_renders=1
[span-count] target=/with-registry?boundaries=5 fizz_renders=15 page_renders=1 server_inserted_html_renders=14
[span-count] target=/with-registry?boundaries=20 fizz_renders=45 page_renders=1 server_inserted_html_renders=44

The HTML still contains the registry style exactly once. The recorded check was:

curl -s 'http://localhost:3000/with-registry?boundaries=5' | grep -o '<style>[^<]*</style>'
# <style>body{font-family:system-ui,sans-serif}</style>

### Current vs. Expected behavior

On `next@16.4.0-canary.50`, the route without a registry produced one server-inserted HTML render per request, while the registry route produced 14 with five boundaries and 44 with 20 boundaries. The same counts were previously observed on canary.48 and stable `16.3.6`. After the first flush, the additional renders produced empty strings; the response still contained the registry's style exactly once.

When every registered callback returns a node known to render no HTML and no other insertion remains, the server should skip the React render for that flush. Each registry callback must still run once and in order, and the response HTML and first style insertion should stay the same. With the local fix's filter applied to a canary runtime, the registry route performed one render for all tested boundary counts and produced byte-identical HTML.

### Provide environment information

```bash
Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.6.0: Tue Aug 18 17:51:33 PDT 2026; root:xnu-12377.161.15.700.19~2/RELEASE_ARM64_T8132
  Available memory (MB): 24576
  Available CPU cores: 10
Binaries:
  Node: 24.19.0
  npm: N/A
  Yarn: N/A
  pnpm: 11.19.0
Relevant Packages:
  next: 16.4.0-canary.50
  eslint-config-next: N/A
  react: 19.3.0
  react-dom: 19.3.0
  typescript: 5.9.3
Next.js Config:
  output: N/A

Which area(s) are affected? (Select all that apply)

Performance, Loading UI and Streaming, CSS

Which stage(s) are affected? (Select all that apply)

next start (local)

Additional context

renderServerInsertedHTML() wraps every callback result in a keyed Fragment, even when the callback returns null, [], or an empty Fragment. That makes the result array nonempty and prevents getServerInsertedHTML() from taking its existing empty-result fast path. The skipped work includes a Fizz render and, for Node streams, an extra event-loop turn. A proposed fix that filters only nodes statically known to render nothing is in the linked pull request. This follows the skip work in #64408 and #85394. Other stages use related code paths but were not measured by this reproduction.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CSSRelated to CSS.Loading UI and StreamingRelated to loading UI (loading.tsx) and streaming.PerformanceAnything with regards to Next.js performance.

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions