Streaming without spinner theater

React 18 made progressive server rendering practical, but useful streaming depended on boundaries shaped around comprehension.

My first streaming prototype was faster and felt busier.

React 18 made streaming server rendering with Suspense part of the stable toolkit. On a synthetic P6X4 decision-report route, I could send the page frame and readily available summary while slower analysis and recommendation work continued. The previous route waited for every query before returning useful HTML.

I wrapped each asynchronous data source in its own Suspense boundary. Bytes reached the browser earlier. The page responded by displaying five skeletons, three spinners, and a chart that moved twice while the reader tried to understand it.

The server had become progressive. The reading experience had become episodic.

I had optimized delivery units without designing an order of meaning.

The route was a controlled experiment

P6X4's report was not a real business dashboard. I built it from synthetic document and workflow data to test one question: can a person understand a bounded operational decision before every supporting calculation finishes?

The route contained:

  • Account-like context and report identity.
  • A date range and source revision.
  • Headline workflow state and important totals.
  • A chart of completion and uncertainty over time.
  • A list of exceptional workflows requiring attention.
  • Generated rule-based recommendations.

I added deterministic latency, failure, stale data, empty results, and long content. No production users or commercial metrics were involved. A small invited review used tasks such as “find the oldest unresolved outcome” and “decide whether this report is current enough to use.”

The setup let me compare complete server rendering with several streaming arrangements under exactly the same data and network conditions.

A query was not a user-facing boundary

The backend exposed separate queries for context, summary, chart series, exceptions, and recommendations. Mirroring those calls in Suspense was mechanically convenient.

It produced fragments that did not always make sense independently.

A chart without its summary invited interpretation before the scope was visible. Recommendations without current exceptions appeared generic. The report title and date range were essential to every number. Navigation and stable identity were useful immediately.

I wrote a comprehension dependency graph instead of a query graph:

report identity and scope
  └─ decision summary
       └─ chart + exceptions
            └─ recommendations

The graph did not mean every node had to wait for the previous computation to start. Work could begin concurrently. It meant a later region should not be revealed as independently meaningful before its interpretive prerequisites.

Streaming allowed concurrent preparation and editorial revelation to be separate choices.

The first useful segment needed a complete sentence

The initial frame originally contained navigation, an empty report card, and several placeholders. It arrived quickly and said almost nothing.

I defined the first useful segment as the smallest response that could answer:

  • Which report is this?
  • What time and source revision does it cover?
  • Is the reader allowed to see it?
  • Is the report current, stale, or still being assembled?
  • What stable navigation remains available?

That segment included report identity, scope, freshness, and a structural heading for each later region. It excluded the optional recommendation result and the expensive chart points.

This increased the time to the first byte slightly because authorization and identity were no longer postponed behind a decorative shell. It improved the time to the first intelligible state.

The earliest chunk became a complete sentence rather than punctuation.

I distinguished preparation from revelation

The server could start summary, chart, exception, and recommendation work as soon as authority and report scope were known. If I fetched them serially according to visual order, coherent streaming would become unnecessarily slow.

I launched independent reads concurrently and grouped their revelation:

const summary = loadSummary(scope)
const chart = loadChart(scope)
const exceptions = loadExceptions(scope)
const recommendations = loadRecommendations(scope)

The analysis boundary awaited chart and exceptions together because they explained one another. The recommendation boundary could resolve later and fail locally. The summary had its own coherent dataset and appeared before analysis.

I was careful not to turn this snippet into a universal pattern. Data access, caching, and framework integration affected when work actually began. The important model was that backend concurrency did not force the browser to reveal every completion instantly.

The page was allowed to wait a little longer for meaning.

Stable geometry was a content decision

The first chart fallback guessed at its final height. Recommendation placeholders guessed at row count. Long labels and empty data invalidated both guesses, shifting the primary controls while a reader moved toward them.

I mapped region geometry under representative states:

  • Short and long report titles.
  • One, five, and no exceptions.
  • Narrow mobile width and wide desktop width.
  • 200 percent text zoom.
  • Translated-length labels in synthetic fixtures.
  • Error and stale states.

The initial document contained stable headings and region order. The analysis fallback reserved a tested minimum height and named the report scope. Recommendations appeared below the task-critical region, so their expansion did not move an action target.

I did not make placeholders resemble finished data more closely. A fictional chart is a poor promise. Stable structure and honest pending language were enough.

“Loading…” repeated five times described the browser's condition, not the reader's task.

I rewrote fallbacks around what remained pending:

  • “Preparing the decision summary for 1–14 December.”
  • “Comparing completed and unresolved workflows.”
  • “Checking this report for conditions worth reviewing.”

The labels appeared only after a short delay to avoid flicker. They used the same report scope already established; a fallback did not introduce a different identity or freshness claim.

For very long work, the interface explained whether the reader could continue elsewhere and how the result would persist. The report's normal calculations were bounded enough that background continuation was not needed, but the rule came from Z29C: accepted work should have durable state, not depend on a spinner remaining mounted.

The wording remained concise. Progress copy can become theater too when it narrates implementation detail without adding a decision.

Skeletons were appropriate only for predictable form

I kept a skeleton for one compact numeric summary whose geometry was tightly bounded. It preserved label locations and did not pretend to know values.

I removed skeletons for the chart, exception list, and recommendations. Their final shapes depended on data. A short textual pending state was more honest and caused less visual noise.

The remaining skeleton respected reduced motion and used no shimmer. It had sufficient contrast as a placeholder without looking like disabled content. Its accessible representation was the region label and pending text, not a row of announced decorative blocks.

The choice was not “skeletons versus spinners” as a style preference. It depended on whether the eventual structure was predictable and whether reserving it helped reading continuity.

Progressive rendering made partial failure visible. That was valuable only if the interface explained what remained trustworthy.

I wrote a failure contract for every boundary:

  • Which earlier content remains valid?
  • Can this region retry independently?
  • Does failure change the meaning of already rendered information?
  • May cached content remain, and with what age?
  • Which log and recovery identity explains the failure?

Recommendation failure did not invalidate summary or analysis. The region could show a specific absence and a safe read retry. Analysis failure left the report identity and current summary visible but removed recommendation claims that depended on the missing evidence.

If report scope or authorization failed, the route did not reveal cached analysis underneath an unknown identity. The primary frame failed disclosure-safely.

Suspense and error boundaries therefore shared the same semantic map.

Empty was not a loading failure

A report with no exceptional workflows initially showed three placeholder rows and then an empty message. The sequence implied that content had vanished.

I made emptiness a first-class successful state. When the summary already knew the exception count was zero, the analysis region could render its empty explanation immediately and omit unnecessary work. When a slower query determined emptiness, the fallback named the region without predicting rows.

The empty state included scope and freshness: “No unresolved workflows were found through source revision 1842.” It did not say “Everything is fine,” which would overstate what the report measured.

Streaming design improved when pending, empty, error, stale, and successful content were designed together rather than as successive afterthoughts.

P6X4's search and analytical projections could lag behind document authority. A fast cached report might arrive before current freshness evidence.

I included source revision and generated time in every report region. Regions that formed one conclusion had to use a compatible source boundary. A recommendation could not refer to analysis from a different revision without saying so.

During a transition, previous content could remain visible if its scope stayed explicit. The heading read “Report through revision 1837” until revision 1842 settled. A small local status said that an update was in progress. I did not fade the whole page, because lower contrast conveyed uncertainty without identifying it.

If authorization changed, previous content did not remain merely for continuity. Safety overrode visual stability.

T04P's early lesson returned: connected and rendered are not synonyms for current.

Hydration remained a separate milestone

Streaming HTML earlier did not make every visible control ready.

The report's filters and export control used client code. In the first prototype, they appeared active in server HTML before hydration attached behavior. Fast clicks seemed lost.

I reduced client responsibility. Date-range changes used ordinary link or form semantics as a functional baseline. Client enhancement made transitions smoother but did not own the only path. Controls that genuinely required client state remained visibly unavailable until ready, without blocking reading.

I measured:

  • First response bytes.
  • Stable report identity.
  • Decision summary visible.
  • Analysis understandable.
  • Primary controls usable.
  • Optional completion.

The interval between visible and usable stayed in the performance story. Server rendering did not earn credit for an affordance the browser could not honor.

The local development server flushed chunks immediately. A reverse-proxy configuration buffered them until a threshold, turning progressive server work back into one delayed response.

I tested the assembled local path with production-like compression and proxy behavior. Response headers, buffering settings, error handling, and cache rules were part of the experiment. A streaming API inside the application did not guarantee streaming at the browser.

I also tested a client disconnect. Server work that could be cancelled without consequence stopped. Durable or shared work did not infer cancellation from a closed connection. The report was read-only, so most calculations could end when no consumer remained.

Infrastructure was allowed to influence the design. I refused to present framework capability as delivered value until the bytes actually arrived progressively.

The stable report description changed rarely. Summary data changed with workflow state. Recommendations could be recomputed and were lower consequence. One route-wide cache rule was too broad.

I documented each region's source, freshness, authority, and invalidation. Server components consumed domain queries through that policy rather than calling storage casually. Cached results carried revision identity so the renderer could determine whether regions were compatible.

The interface exposed freshness where a decision depended on it. It did not show internal cache terminology. “Includes activity through 14:08” was useful; “cache hit” was not.

I avoided building an elaborate caching abstraction around still-evolving framework behavior. Stable domain freshness rules remained below a thin integration layer.

Accessibility changed the reveal sequence

The first version announced each fallback and each completion through separate live regions. A screen reader heard a sequence of loading and loaded messages unrelated to reading priority.

I kept the document outline and landmarks stable from the initial response. Each later region had a heading already present. Most content arrived silently into its labelled region. A polite summary announced when the decision summary became available; optional recommendations did not interrupt.

Focus stayed with the initiating link or filter. Revealed content never stole it. If a route navigation changed the report, the main heading received the appropriate navigation focus behavior once identity was ready.

Error and stale messages were associated with their region and available during landmark navigation. Decorative skeleton elements were hidden from the accessibility tree.

The result sounded like one document unfolding, not a control room announcing component mounts.

Responsive design changed what “later” meant

On wide screens, recommendations appeared beside analysis. On a narrow screen, the same DOM order placed them before the exception list, because the original grid was organized visually.

I changed source order to follow task priority: frame, summary, analysis, exceptions, recommendations. CSS placed lower-priority regions beside earlier ones only when space allowed. Streaming reveal never changed that reading or focus order.

I also tested region arrival while zoomed and while scrolled near the primary action. Optional content appeared below active work so it could not move the target under a pointer or keyboard user.

The server did not know every final viewport detail, but semantic source order provided a safe default across layouts.

Cancellation did not cross into durable intent

Changing the date range could make an earlier report calculation irrelevant. That computation was safe to abandon.

Exporting or delivering a report was different. Once the server accepted a stable intent, navigation and disconnected streams did not cancel it implicitly. The action returned a durable workflow identity, and the interface could resume its state later.

I kept these paths separate in code and language. A transition pending flag represented render work. It never became the authoritative exporting or delivering state.

This prevented React's interruptible rendering model from leaking into consequential application semantics.

I compared sequences, not final screenshots

The final result looked similar across implementations, so a screenshot could not explain why one felt calmer.

I built deterministic checkpoints:

  1. Stable frame only.
  2. Summary delayed.
  3. Analysis partially computed but unrevealed.
  4. Analysis settled.
  5. Recommendations delayed or failed.
  6. Navigation initiated during pending work.

At each checkpoint I recorded DOM structure, accessible names and states, geometry, source revisions, visible controls, and a screenshot. The test failed if a later region appeared before its prerequisite, focus moved unexpectedly, a control became actionable on partial authority, or layout crossed its budget.

This temporal fixture caught regressions that performance totals missed.

Time to first byte made the old page look uniformly slow and the first streaming prototype look excellent. Neither described when the report became useful.

I measured route-specific milestones:

  • Time to recognizable report.
  • Time to coherent decision summary.
  • Time to actionable exception state.
  • Time until controls responded.
  • Layout movement before primary readiness.
  • Optional recommendation completion.
  • Percentage of scenarios with local versus route-level failure.

The second design sometimes delivered fewer chunks and a slightly later first byte. It reached a coherent summary earlier, moved less, and let optional variability remain optional.

I kept raw server and browser traces for diagnosis. The milestone names kept the optimization connected to the task.

The content graph guided prioritization

The comprehension dependency graph became a lightweight editorial artifact beside the route.

For every region it recorded:

  • What the reader can conclude from it.
  • Prerequisite context.
  • Whether it may be stale.
  • Whether it can fail independently.
  • Whether it contains an action.
  • Its source revision relationship.

The graph exposed one recommendation card that was merely restating the headline status. I removed it instead of streaming it more efficiently. Another optional chart offered detail available in the exceptions list and moved below the fold.

Performance work became content editing. Fewer computations and regions improved both server work and comprehension.

Progressive enhancement kept the frame useful

With client JavaScript blocked or delayed, the report still rendered identity, summary, analysis, and links. Date selection submitted through a normal request. Error recovery for safe reads used links or forms.

Client code enhanced transitions, chart exploration, and local filtering. It did not own access to the report or the primary route.

This reduced the hydration critical path and made the visible-before-interactive gap less consequential. It also provided a clean failure mode when a bundle failed to load.

The design echoed M31V's older principle: one released artifact should remain understandable under imperfect delivery. React 18 expanded what the server could send progressively; ordinary web semantics kept the early content usable.

The first prototype used shimmer and spinning indicators to make progress noticeable. The second used stable structure, concise status, and meaningful content arrival.

Reduced-motion testing removed the animations and revealed that they had been carrying most of the feedback. I improved the labels and region relationships, then kept the quieter version for everyone.

Content arrival itself was enough change. Additional motion did not make progress more truthful.

The interface could feel alive through responsiveness and continuity without performing busyness.

The architecture became an editorial tool

P6X4's data classification and modular boundaries helped determine what could stream safely.

Authoritative report scope came first. A bounded summary followed. Analysis combined a projection and exception set tied to one source revision. Recommendations were derived and optional. External effects remained durable workflows outside the rendering lifecycle.

Because those meanings were explicit, the route did not have to equate each storage call with one screen region. Server code could fetch concurrently and compose an interpretable response.

Architecture enabled streaming when it described relationships among truths, not merely locations of services.

It had four visible stages rather than nine. A fast run often showed only two because short pending states never appeared. The chart and exceptions arrived together. Recommendations could fail without a cascade. The layout remained stable while the reader moved through it.

The implementation still used streaming server rendering, Suspense, and concurrent work. It simply stopped advertising each capability through a fallback.

That restraint made the technical benefit easier to feel. Useful context appeared before optional computation. Slow work no longer held the whole response. Browser code stayed smaller. Partial failure had a truthful home.

Progressive rendering requires editorial judgment

Streaming is often explained through plumbing: chunks, boundaries, server work, caches, and hydration. The mechanism matters. The product decision is editorial.

What does the reader need first? Which pieces form one claim? What may be stale? What can fail without invalidating what came before? Which action needs a coherent authority view? What remains useful if later work never arrives?

The first prototype answered those questions with the query graph. The second answered them with a reading sequence.

Sometimes it delivered fewer chunks. It was more progressive because every arrival moved the reader into a new, understandable state.

A spinner proves that software knows it is waiting. Good streaming proves that software knows what is worth showing before the wait ends.

That distinction turned transport progress into deliberate, comprehensible product progress.