Boundary Tightness
Static shells, client components — one schema for how much a framework boundary overspends, and how agents use the score to drive migrations. Jude Gao · July 2026.
React Server Component frameworks partition every app along boundaries:
Suspense boundaries split the prerendered static shell from dynamic holes;
"use client" splits the server module graph from the hydrated client
bundle. Each boundary's mechanism is coarse — a Suspense boundary reveals
atomically, a client directive poisons every transitive import — while the
need that motivates it is fine-grained: only the UI that data-depends on
an awaited value must block; only the code that touches state, effects, or
browser APIs must ship to the client. Frameworks validate boundaries
binarily (does one exist where required?), never economically (how much
do they overspend?). We define a normalized tightness score in
— the ratio of the necessarily-affected region to the
actually-affected region — with a route-level precision/efficiency rollup,
instantiate it twice (shell tightness for I/O push-down under
cacheComponents; client tightness for "use client" placement), and show
how the scores act as reward signals for agentic migration loops.
Boundaries are coarse; need is fine
Every instance of this metric family has the same shape.
A trigger (an await, a directive) activates a framework mechanism whose granularity is structural, not semantic: it affects everything inside a region determined by where the trigger sits, not by what actually depends on it. Call that the actual region . The semantics of the code define a lower bound — the necessary region that no restructuring could exempt. With a consistent size weight :
Tightness means the boundary spends exactly what the
semantics demand — unavoidable and perfectly scoped. The gap
is pure structural waste, and every instance has
a known rewrite (a lever) that shrinks it. The table below shows the two
instances this paper develops; the schema accommodates more (e.g.
"use cache" scope granularity vs. actually-shared computation).
| shell tightness (§2–4) | client tightness (§5) | |
|---|---|---|
| trigger | an await of uncached I/O | a "use client" directive |
| coarse mechanism | nearest <Suspense> reveals atomically | imports of a client module become client |
| actual region | boundary content | client module subgraph |
| necessary region | UI data-dependent on the awaited value | code needing state/effects/events/browser APIs |
| weight | JSX-node census (≈ visible area) | module-size census (≈ bundle bytes) |
| escape hatch | pass the promise down | pass server JSX as children |
| cost of waste | smaller static shell (slower first paint) | bigger bundle, more hydration |
| build-truth validation | shell HTML, postponed state | client-reference manifests, chunk sizes |
Even the escape hatches correspond: both thread an opaque value through a boundary so the boundary stops capturing things it doesn't need.
Instance 1: shell tightness
The problem in one picture.
An await in a server component halts that component's entire output. What
the user loses is larger still: React holds back everything under the
nearest enclosing Suspense boundary until all of its suspended descendants
resolve. If no boundary exists, the whole route blocks (and under Cache
Components, the build fails). The structural question is therefore not
whether you block — dynamism is usually a product requirement — but how
much unrelated UI you take hostage.
Nav and Promo prerender into the shell. The await inside UserPanel blocks exactly the boundary content ; the data-dependent region is the lower bound no restructuring can beat. Here and nearly coincide: this structure is tight.Definitions
The general form, instantiated for the shell: the trigger is an await site , the actual region is the blocked region , the necessary region is the data-dependent region .
Fix a route and let range over its await sites: syntactic points
(await, .then, React use()) that resolve a dynamic source — uncached
fetch, a database call, cookies(), headers(), params,
searchParams — outside any "use cache" scope. (We keep the
/ names in this instance; the rollup below transfers
verbatim to any instance.)
- Necessary region . The union of JSX subtrees that data-depend on the awaited value: every element interpolating it and every component receiving a prop derived from it, transitively. This UI cannot exist before the data arrives; it is a lower bound on blocking.
- Blocked region . The content of the nearest Suspense boundary enclosing the awaiting component (the whole route if none). React reveals a boundary atomically, so this is precisely what the user cannot see.
- Weight . Any consistent proxy for "amount of UI". We use the JSX-node count of the statically expanded subtree (components inlined recursively, cycle-guarded; unresolvable references count 1 and are reported). Because the score is a ratio, need only be monotone — and it can be validated against build truth: DOM-node or pixel deltas between the prerendered shell and the full render.
The per-await score is
with meaning the blocking is unavoidable and perfectly scoped.
From one await to a route score
A route has many awaits, so we need one number per route — but per-await scores do not combine by averaging or summing, for two reasons:
- Shared boundaries double-count. If
cookies()andfetchUser()are awaited under the same Suspense boundary, they have the same . Summing over both awaits would count every blocked component twice, and "fixing" one of the two awaits would appear to halve the damage when the user-visible hole is unchanged. - Averaging over-weights trivia. The mean of is the same whether the await blocks a footnote or half the viewport. An average of ratios forgets how much UI each ratio is about.
Both problems disappear if we merge regions first and divide once. Collect the route's three disjoint territories:
Everything outside is the static shell. The route then gets two complementary scores, each answering a different question:
"Of the UI that blocks, what fraction truly had to?" Judges how well the boundaries are scoped, ignoring how big the page is. This is per-await tightness generalized to the whole route.
"Of the whole page, what fraction is where it belongs?" Only needlessly blocked UI counts against the route; UI that must block costs nothing. = the shell is as large as this page's data dependencies permit.
Both live in and equal for a route with no uncached awaits. The figure below lays the two formulas over one picture, using the numbers from example (b) of the worked example in the next section.
Why keep two numbers?
Because they deliberately disagree on one important case. Take a 100-node page with a single sloppy await: , . Precision is — locally, that boundary is badly scoped. Shell efficiency is — globally, the user still gets 96% of the page instantly. Neither number alone tells the whole story: precision without size context manufactures urgency over footnotes; shell efficiency alone would let every boundary rot a little. So: assert on shell efficiency (it tracks what users experience), rank the worklist by wasted weight (it finds the biggest prize), and read per-boundary precision as the hygiene diagnostic. Hop distance (await site to first real use) survives alongside as a per-violation diagnostic — it names the fix, while the scores price the cost.
Awaits inside "use cache" scopes are exempt (;
they resolve at build/cache-fill time). Void awaits (connection(),
io()) have no value and are checked for placement only. An awaited value
that is never rendered scores — a dead
fetch holding UI hostage. Promise.all elements are scored independently
against their common .
Worked example: three structures, one page
The same page — : Nav , Promo , page chrome , RenderUser — in three shapes an agent typically encounters, in order.
Page, no boundarytightness 0.24 · shellEff 0.24 (build fails)
tightness 0.40 · shellEff 0.65 (build passes!)
tightness 1.00 · shellEff 1.00
Promo () from the shell. is exactly the agent's prize for fixing it.Instance 2: client tightness
"use client" placement: the same anatomy, one level down.
The server/client split partitions the module graph instead of the render
tree. A "use client" directive marks a boundary file; that file and
every module it transitively imports become client modules — downloaded,
parsed, and hydrated in the browser. The mechanism is again coarse: the
directive captures by reachability, not by need. Mark a header
component as client because it contains one interactive toggle, and the
navigation, logo, and search markup it imports all ship to the client with
it.
- Necessary region . Modules (or components within
them) that semantically require the client: they call hooks
(
useState,useEffect,useContext,useOptimistic, …), attach event handlers (on[A-Z]*props), touch browser globals (window,document,localStorage), or import client-only libraries. This is the lower bound: no restructuring can render this code on the server. - Actual region . The client module subgraph reachable from the directive file through imports. This is precisely what the bundler ships — and it is checkable against build truth, because Next.js writes the reachable client set into its client-reference manifests.
- Weight . Module-size census (AST-node or byte count) rather than JSX census: the cost of client waste is bundle bytes and hydration work, not blocked pixels. Same principle as before — need only be monotone, and the build offers ground truth (per-route chunk sizes) to validate against.
Then, exactly as before: per directive, and per route
"of the code that could stay on the server, how much does."
header.tsxLevers
In ranked order of applicability: (1) push the directive down to the
interactive leaf (figure above); (2) thread server JSX through — a client
wrapper (context provider, animation shell) can receive server-rendered
subtrees as children/props without dragging them into the bundle, the
exact analog of passing a promise through a Suspense boundary; (3) split
mixed files so the interactive part is importable alone; (4) replace a
client-only dependency with a server-safe one. As with the shell,
per-directive tightness ranks the worklist by wasted weight, and
serverEff is the route-level assertion:
test("client boundaries are tight", () => { for (const route of report.routes) { expect(route.serverEfficiency, route.explainClient()) .toBeGreaterThanOrEqual(0.85);}});
Two familiar refinements carry over. First, the binary ancestor of this
metric — counting "use client" files, or capping "client files ≤ 50% of
source files" — has the same flaw as counting awaits: a count punishes
granularity (ten tight leaf directives beat one hoisted directive, but
score worse) and ignores weight. Second, build truth exists here too: the
client-reference manifests under .next/server/app record exactly which
modules shipped, so the static can be validated the same
way shell is validated against the prerendered HTML.
Driving agentic migrations
Constraints select software; scores steer it.
Why should a metric exist at all, rather than another lint rule? The answer is about what tests do to a generator of code. An agent samples from the space of all programs it could write. Every assertion you add rejects part of that space: the type checker rejects code that doesn't compile, e2e tests reject code that doesn't work, and structural assertions — like the ones in this paper — reject code that works but is built wrong. Each constraint is a funnel stage: what comes out the narrow end is not better generation, it is better selection.
Binary constraints alone make agents random-walk: inside/outside is all
they report, so the agent learns nothing from a failure except "try again".
This is the practical argument for normalization: a score in
converts the innermost boundary into a potential field.
doesn't just reject — it says how far, and (through the ranked
worklist) along which dimension to move. The build's binary check is the
outer funnel wall; shellEff is the slope inside it.
The metrics turn migration from a checklist into gradient descent: the
assertion is the objective, per-violation wasted weight is the ranking, and
the diagnostics name the rewrite. The loop below is the next-doctor
equivalent of a failing e2e test forcing behavioral correctness — here it
forces structural correctness. We show it for the shell instance; the
client instance runs the identical loop with serverEff as the objective
and the levers of §5 in step 3.
vitest run shell.test.ts — static analysis of app/**next build: route still ◐, shell grew, no new errorsshellEff may only rise- value only forwarded () → push the await down / pass the promise
- , await already low → tighten or split the Suspense boundary
- data shared or slow-changing →
"use cache"+cacheLife()(exempts the await) - (value unrendered) → delete the fetch or move it out of render
.meta postponed state, shell HTML); step 5 makes progress monotone so parallel agents cannot regress each other.The assertion an agent iterates against
import { describe, expect, test } from "vitest";import { analyzeShellTightness } from "../doctor/shell-tightness"; const report = analyzeShellTightness("./app", { ioModules: ["@/lib/db", "@/lib/api"], // designate your I/O layer}); describe("cache components: shell structure", () => { test("every route keeps a tight static shell", () => { for (const route of report.routes) { expect(route.shellEfficiency, route.explain() // ranked violation table on failure ).toBeGreaterThanOrEqual(0.8);} }); test("no hoisted awaits (push-down distance is zero)", () => { expect(report.violations.map(v => v.summary())).toEqual([]); }); test("ratchet: never worse than the committed floor", () => { expect(report.project.shellEfficiency) .toBeGreaterThanOrEqual(readFloor(".shell-ratchet.json")); });});
A failure prints the worklist the agent consumes — highest wasted weight first, with the fix named by the diagnostics:
Case study: one migration, four scores
| step | agent action | mechanism | build | shellEff |
|---|---|---|---|---|
| 0 | (start) awaits hoisted in page.tsx, no boundaries | — | build fails | 0.08 |
| 1 | wrap whole page in one <Suspense> | boundary added, still sloppy | ◐ (empty shell!) | 0.11 |
| 2 | push fetch into RevenueChart, Invoices; per-leaf boundaries | per widget | ◐ | 0.72 |
| 3 | "use cache" + cacheLife("hours") on shared nav data | await exempted; joins shell | ◐ | 0.93 |
Step 1 is the canonical trap: the build passes and the route shows ◐ "partial prerender", but the shell is one fallback spinner. The framework's binary check cannot see the difference between steps 1 and 3; the score can — which is exactly why the agent needs it as the reward signal rather than the build exit code.
Why this score, and not…
Alternatives, and why an agent-optimized metric must reward the rewrite you actually want.
- …normalized hop distance, e.g. ? Dimensionless in the wrong way: insensitive to how much UI hangs off each hop, and gameable — an agent can reduce without growing the shell by a single node. Distance names the fix; it cannot price the cost.
- …fraction of components that are async? Punishes dynamism itself. A fully personalized page can be perfectly structured (); a mostly-static page can be terribly structured. Product requirements set how much is dynamic; engineering controls only the scoping — the metric must isolate the latter.
- …build output alone (shell bytes / postponed state)? It is the ground truth, but it is non-attributable: it tells you the shell is small, not which await to blame or where the value was actually needed. We use build truth as step-4 verification and to calibrate , source analysis for attribution and the gradient.
- …counting
"use client"files (or awaits)? Counts punish granularity: pushing one hoisted directive down into three tight leaf directives triples the count while shrinking the bundle. Any metric an agent optimizes must reward the rewrite you actually want; only weighted necessary-over-actual does.
One schema, many boundaries: per
trigger, efficiency per route as the assertion (shellEff for Suspense
holes, serverEff for client bundles), wasted weight
as the agent's worklist ordering, and
instance-specific levers as diagnostics. One number to assert per boundary
type, one gradient to descend.