In building high-stakes software, the most dangerous architectural decision is treating all data as if it arrives at the same speed. During my eight years of professional engineering, I have seen this assumption break production systems repeatedly. When I served as the first engineering hire at Synapsis Medical Technologies, we were building a HealthTech AI platform that integrated everything from real-time wearables to HIPAA-aligned RAG pipelines. In that environment, "loading" wasn't a binary state; it was a spectrum of latencies ranging from 50ms for a cached user profile to 15 seconds for a complex clinical LLM inference.
If you treat a Next.js page as a monolithic unit of work, you are effectively tethering your fastest data to your slowest dependency. The result is a "white screen of death" or a blocking spinner that frustrates users and kills perceived performance. Next.js streaming, powered by React Suspense, is the solution, but it is frequently misapplied. The goal is not just to stream; the goal is to place Suspense boundaries exactly where the latency resides.
The Problem: The All-or-Nothing Hydration Trap
In traditional Server-Side Rendering (SSR), the server must fetch all data for a page before it can even begin to generate the HTML. Once the HTML is sent, the browser must download all the JavaScript for that page before it can hydrate the components and make them interactive.
In a clinical AI context, where I owned the architecture from 0 to 1, this was unacceptable. If a physician is waiting for a RAG-based summary of a patient's history, they should still be able to interact with the patient's basic vitals and demographic data which are already available. If you wrap the entire page in a single server-side fetch, the doctor sees nothing until the slowest LLM call completes.
Next.js 13+ and the App Router changed this by allowing us to break the page into chunks. However, I often see developers wrapping their entire layout.tsx or page.tsx in a single Suspense boundary. This is just SSR with a different name. It doesn't solve the latency gap; it just moves the waiting period from the server to the client.
Architecture and the Granularity Trade-off
When I scaled the engineering team from 0 to 21 engineers at Synapsis, one of the primary architectural hurdles was defining how we handled data fetching across our React Native and Next.js stacks. We had to balance two competing forces:
- Network Overhead: Too many small, streamed components lead to "waterfalling," where one component waits for another to finish before it can even start its own fetch.
- User Experience: Too few boundaries lead to the "jank" of a page that looks ready but is completely frozen.
The strategy I implemented focused on identifying "Critical Path" data versus "Enrichment" data. Critical Path data (like a patient ID or a basic UI shell) should be part of the initial server response. Enrichment data (like AI-generated insights or complex FHIR/HL7 integrations) must be deferred via Suspense.
In my experience shipping over 18 production applications, the most resilient architecture is one where the UI is skeletonized at the component level. This allows the server to send the "shell" immediately, while the heavy lifting happens in parallel background streams.
A Worked Example: The Clinical AI Dashboard
Consider a dashboard that displays patient vitals and an AI-generated summary. The vitals come from a fast PostgreSQL database, but the summary comes from a RAG pipeline. At Synapsis, we ran a HIPAA-aligned RAG pipeline at 99.9% uptime, but even with high availability, LLM inference is inherently slower than a standard DB query.
Here is how to structure that boundary effectively:
// The Main Page Component (Server Component)
export default async function PatientDashboard({ patientId }) {
return (
<div className="grid grid-cols-2 gap-4">
{/* Fast Data: Rendered immediately or with minimal delay */}
<section>
<PatientVitals patientId={patientId} />
</section>
{/* Slow Data: Wrapped in a targeted Suspense boundary */}
<section>
<Suspense fallback={<SummarySkeleton />}>
<ClinicalAISummary patientId={patientId} />
</Suspense>
</section>
</div>
);
}
// The Slow Component (Server Component)
async function ClinicalAISummary({ patientId }) {
// This call might take 3-5 seconds
const summary = await getRAGSummaryFromPipeline(patientId);
return (
<div className="p-4 bg-blue-50">
<h3>AI Insights</h3>
<p>{summary.text}</p>
</div>
);
}
By isolating ClinicalAISummary, the PatientVitals component can be hydrated and interactive almost instantly. The user can scroll through heart rate data while the RAG pipeline is still processing the clinical notes.
What it Cost to Learn: The CI/CD and Reliability Factor
Architecture isn't just about how the code looks; it's about how it survives production. When we overhauled our CI/CD across five production systems, cutting release cycles from 2 days to 4 hours, we learned that granular streaming makes testing more complex.
When you move to a streaming model, you are no longer testing a single page load. You are testing a sequence of states. We found that if a streamed component fails, it can potentially hang the entire stream if not handled with Error Boundaries.
In the RAG pipeline I managed, we had to ensure that if the AI service timed out, the rest of the dashboard didn't crash. This required a strict pairing of Suspense with ErrorBoundary. If the streaming data fails, the UI should gracefully degrade to a "Retry" state without affecting the rest of the application.
Practical Recommendations for Systems Architects
Based on my experience building and scaling full-stack architectures, here are the rules for placing your boundaries:
- Identify the "Time to Interactive" (TTI) Blockers: Use a profiler to see which data fetches exceed 200ms. Anything over this threshold is a candidate for a Suspense boundary.
- Avoid Nested Waterfalls: Do not put a Suspense-wrapped component inside another Suspense-wrapped component unless the second one truly depends on the first. This creates a sequential loading experience that feels slower than a single long wait.
- Use Meaningful Skeletons: A generic spinner is often worse than no spinner. Use CSS skeletons that mimic the final layout of the data. This reduces layout shift (CLS), which is a key Web Vital.
- Prioritize the "Above the Fold" Content: If a slow data fetch is for content that appears at the bottom of the page, it should always be streamed. There is no reason to delay the initial paint for content the user hasn't even scrolled to yet.
- Pair with Prefetching: In Next.js, use the
Linkcomponent to prefetch pages, but be aware that prefetching will only fetch the static parts of the page. The streamed data will still fetch on navigation, so your boundaries must be robust.
Conclusion
Streaming is not a "set it and forget it" feature of Next.js. It is a tool for managing the inherent unpredictability of distributed systems and external APIs. Whether you are integrating wearables data or serving clinical AI, the goal is to decouple the user's ability to act from the system's need to process. By placing Suspense boundaries exactly where the latency resides, you create applications that feel fast, even when the underlying data is slow. In my transition to independent systems architecture, this remains the most effective way to ensure that complex, data-heavy platforms remain performant at scale.
Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.
Top comments (0)