← Jude GaoBoundary Tightness
Chapters ▾
A paper · normalized structural metrics for Next.js

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.

Abstract

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 [0,1][0,1] — 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.

Section 1

Boundaries are coarse; need is fine

Every instance of this metric family has the same shape.

A trigger xx (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 A(x)A(x). The semantics of the code define a lower bound — the necessary region N(x)A(x)N(x) \subseteq A(x) that no restructuring could exempt. With a consistent size weight W()W(\cdot):

tightness(x)=W(N(x))W(A(x))(0,1],efficiency=1W(xA(x)xN(x))W(total).\mathrm{tightness}(x) = \frac{W\big(N(x)\big)}{W\big(A(x)\big)} \in (0,1], \qquad \mathrm{efficiency} = 1 - \frac{W\big(\bigcup_x A(x) \setminus \bigcup_x N(x)\big)}{W(\mathrm{total})}.

Tightness =1=1 means the boundary spends exactly what the semantics demand — unavoidable and perfectly scoped. The gap ANA \setminus N 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 xxan await of uncached I/Oa "use client" directive
coarse mechanismnearest <Suspense> reveals atomicallyimports of a client module become client
actual region A(x)A(x)boundary content B(a)B(a)client module subgraph
necessary region N(x)N(x)UI data-dependent on the awaited valuecode needing state/effects/events/browser APIs
weight WWJSX-node census (≈ visible area)module-size census (≈ bundle bytes)
escape hatchpass the promise downpass server JSX as children
cost of wastesmaller static shell (slower first paint)bigger bundle, more hydration
build-truth validationshell HTML, postponed stateclient-reference manifests, chunk sizes
Two instances of one schema

Even the escape hatches correspond: both thread an opaque value through a boundary so the boundary stops capturing things it doesn't need.

Section 2

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.

in the static shell blocked by the await data-dependent (unavoidable)
Page
Nav w=5
Promo w=6
Suspense
UserPanelawait fetchUser()
RenderUser w=4
Anatomy of one await. Nav and Promo prerender into the shell. The await inside UserPanel blocks exactly the boundary content B(a)B(a); the data-dependent region U(a)U(a) is the lower bound no restructuring can beat. Here BB and UU nearly coincide: this structure is tight.
Section 3

Definitions

The general form, instantiated for the shell: the trigger is an await site aa, the actual region is the blocked region B(a)B(a), the necessary region is the data-dependent region U(a)U(a).

Fix a route and let aa 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 UU/BB names in this instance; the rollup below transfers verbatim to any instance.)

  • Necessary region U(a)U(a). 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 B(a)B(a). 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 W()W(\cdot). 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, WW 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

tightness(a)=W(U(a))W(B(a))(0,1],\mathrm{tightness}(a) = \frac{W\big(U(a)\big)}{W\big(B(a)\big)} \in (0,1],

with tightness(a)=1\mathrm{tightness}(a)=1 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:

  1. Shared boundaries double-count. If cookies() and fetchUser() are awaited under the same Suspense boundary, they have the same BB. Summing W(B)W(B) 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.
  2. Averaging over-weights trivia. The mean of {0.9,0.2}\{0.9, 0.2\} is the same whether the 0.20.2 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:

Blocked=aB(a)everything the user waits for, counted onceNeeded=aU(a)everything that data-depends on some awaited valueWasted=BlockedNeededblocked UI that no await actually feeds — the pure loss\begin{aligned} \mathrm{Blocked} &= \textstyle\bigcup_a B(a) && \text{everything the user waits for, counted once} \\ \mathrm{Needed} &= \textstyle\bigcup_a U(a) && \text{everything that data-depends on some awaited value} \\ \mathrm{Wasted} &= \mathrm{Blocked} \setminus \mathrm{Needed} && \text{blocked UI that no await actually feeds — the pure loss} \end{aligned}

Everything outside Blocked\mathrm{Blocked} is the static shell. The route then gets two complementary scores, each answering a different question:

precision=W(Needed)W(Blocked)\mathrm{precision} = \frac{W(\mathrm{Needed})}{W(\mathrm{Blocked})}

"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.

shellEff=1W(Wasted)W(route)\mathrm{shellEff} = 1 - \frac{W(\mathrm{Wasted})}{W(\mathrm{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. 11 = the shell is as large as this page's data dependencies permit.

Both live in [0,1][0,1] and equal 11 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.

W(route)=17W(\mathrm{route}) = 17
Blocked (the Suspense hole), W=10W = 10
precision=410=0.40\mathrm{precision} = \tfrac{4}{10} = 0.40shellEff=1617=0.65\mathrm{shellEff} = 1 - \tfrac{6}{17} = 0.65
The rollup as one bar: every JSX node of the route lands in exactly one of three territories. Precision reads the bar from inside the hole (needed over blocked); shell efficiency reads it from the whole page (one minus wasted over everything). The fix that moves both numbers is the same: shrink the orange segment.

Why keep two numbers?

Because they deliberately disagree on one important case. Take a 100-node page with a single sloppy await: B=5B = 5, U=1U = 1. Precision is 1/5=0.201/5 = 0.20 — locally, that boundary is badly scoped. Shell efficiency is 14/100=0.961 - 4/100 = 0.96 — 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 W(B(a)U(a))W(B(a) \setminus U(a)) (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.

Edge cases

Awaits inside "use cache" scopes are exempt (B=B=\varnothing; 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 tightness=0\mathrm{tightness}=0 — a dead fetch holding UI hostage. Promise.all elements are scored independently against their common BB.

Section 4

Worked example: three structures, one page

The same page — W(route)=17W(\mathrm{route}) = 17: Nav w=5w{=}5, Promo w=6w{=}6, page chrome w=2w{=}2, RenderUser w=4w{=}4 — in three shapes an agent typically encounters, in order.

(a) hoisted to Page, no boundary
Pageawait
Nav
Promo
RenderUser
B=17, U=4B{=}17,\ U{=}4
tightness 0.24 · shellEff 0.24 (build fails)
(b) pushed down, boundary too high
Page
Nav
Suspense (too high)
Promo
UserPanelawait
RenderUser
B=10, U=4B{=}10,\ U{=}4
tightness 0.40 · shellEff 0.65 (build passes!)
(c) tight: await beside its use
Page
Nav
Promo
Suspense
UserPanelawait
RenderUser
B=4, U=4B{=}4,\ U{=}4
tightness 1.00 · shellEff 1.00
The score separates states the framework cannot distinguish. Next.js rejects (a) and accepts both (b) and (c) — the build table shows ◐ partial prerender for both — but (b) needlessly ejects Promo (w=6w{=}6) from the shell. W(BU)=6W(B \setminus U){=}6 is exactly the agent's prize for fixing it.
Section 5

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 N(x)N(x). 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 A(x)A(x). 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 W()W(\cdot). 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 — WW need only be monotone, and the build offers ground truth (per-route chunk sizes) to validate against.

Then, exactly as before: tightness(x)=W(N)/W(A)\mathrm{tightness}(x) = W(N)/W(A) per directive, and per route

serverEff=1W(xA(x)xN(x))W(route modules),\mathrm{serverEff} = 1 - \frac{W\big(\bigcup_x A(x) \setminus \bigcup_x N(x)\big)}{W(\mathrm{route\ modules})},

"of the code that could stay on the server, how much does."

directive on header.tsx
page.tsx
header.tsx w=4"use client"
nav.tsx w=6
search.tsx w=5
theme-toggle.tsx w=3
A=18, N=3A{=}18,\ N{=}3 · tightness 0.17
directive pushed to the leaf
page.tsx
header.tsx
nav.tsx
search.tsx
theme-toggle.tsx"use client"
A=3, N=3A{=}3,\ N{=}3 · tightness 1.00
Client tightness on the module graph (indentation is imports; orange is the client bundle AA). One interactive toggle should not cost the server-rendering of navigation and search. The fix is the same gesture as the shell instance: move the trigger to where the need lives.

Levers

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:

client-tightness.test.ts
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 A(x)A(x) can be validated the same way shell WW is validated against the prerendered HTML.

Section 6

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.

each assertion narrows what an agent may hand backworking software+ structural assertions+ normalized scorecompilesworking (e2e pass)"good"shellEff ≥ 0.8agent loop: the score points inward
Left: assertions as a funnel over the space of programs an agent can produce — the narrow end is selection, not smarter generation. Right: the same idea as nested sets. Binary checks (compiles, e2e) only answer in or out; a normalized score gives the trajectory a direction and a magnitude, so the agent can descend toward the inner set instead of sampling until it lands there.

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 [0,1][0,1] converts the innermost boundary into a potential field. 0.310.31 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.

1 · Measurevitest run shell.test.ts — static analysis of app/**
2 · Rankviolations by wasted weight W(BU)W(B\setminus U)
3 · Rewritetop violation via decision rules
4 · Verifynext build: route still ◐, shell grew, no new errors
5 · Ratchetcommit score floor: shellEff may only rise
Decision rules (step 3)
  • value only forwarded (d>0d>0) → push the await down / pass the promise
  • BUB \gg U, await already low → tighten or split the Suspense boundary
  • data shared or slow-changing → "use cache" + cacheLife() (exempts the await)
  • U=U=\varnothing (value unrendered) → delete the fetch or move it out of render
The migration loop. Steps 1–2 come from the metric; step 4 cross-checks against build truth (.meta postponed state, shell HTML); step 5 makes progress monotone so parallel agents cannot regress each other.

The assertion an agent iterates against

shell.test.ts
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

stepagent actionmechanismbuildshellEff
0(start) awaits hoisted in page.tsx, no boundariesbuild fails0.08
1wrap whole page in one <Suspense>boundary added, still sloppy◐ (empty shell!)0.11
2push fetch into RevenueChart, Invoices; per-leaf boundariesBUB \to U per widget0.72
3"use cache" + cacheLife("hours") on shared nav dataawait exempted; joins shell0.93
The canonical trap

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.

0.50.81.0assertion threshold 0.80123"◐ but empty" — the trapthe binary check misses
Score trajectory across the migration. The agent stops when the assertion passes; the ratchet file pins the high-water mark for every subsequent change to the codebase.
Section 7

Why this score, and not…

Alternatives, and why an agent-optimized metric must reward the rewrite you actually want.

  • …normalized hop distance, e.g. 1/(1+d)1/(1+d)? Dimensionless in the wrong way: insensitive to how much UI hangs off each hop, and gameable — an agent can reduce dd 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 (tightness=1\mathrm{tightness}=1); 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 WW, 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.
Summary

One schema, many boundaries: tightness=W(N)/W(A)\mathrm{tightness} = W(N)/W(A) per trigger, efficiency per route as the assertion (shellEff for Suspense holes, serverEff for client bundles), wasted weight W(AN)W(A \setminus N) as the agent's worklist ordering, and instance-specific levers as diagnostics. One number to assert per boundary type, one gradient to descend.