Strata architecture and SDDMenu
On this page

Full-stack performance scaling: one island versus three

Date: 2026-10-16
Starting main: 546b35ccb708d784ef46bea2eb35a90736a3da79
Branch: test/performance-multi-island-scaling
Tested head (hosted run): 9821d581e44286c8ccaab681821f06d50c905e25
Command: STRATA_PERF_ISLANDS=1|3 pnpm test:full-stack-performance (runner tools/performance/full-stack.ts, fixture tests/full-stack-performance/fixture)
Builds on: full-stack-performance-baseline.md (historical numbers there are not restated or altered).

This qualifies how Strata's bundle overhead scales from one interactive island to three distinct ones, using the existing four-arm benchmark unchanged in method. It is evidence, not a qualification: it asserts correctness and confidentiality strictly and asserts no size or latency threshold. R13 stays open; Strata 1.0 is not qualified. Labels: MEASURED (observed here), PROPOSED (candidate, unenforced), NOT MEASURED, NOT QUALIFIED.

Verdicts first

VerdictResult
One-island benchmark regressionGO (local numbers equal PR #86 to the 0.1 KiB; hosted browser bytes byte-identical to local)
Three-island functional equivalenceGO (all four arms: SSR, three public APIs, unknown-product 404, boundaries)
Three-island hydration and interactionGO (original DOM nodes reused, one request and one state update per click, no cross-island effect)
Browser/server graph confidentialityGO (both profiles, both presets, three canaries, positive controls; 11 negative controls rejected at three islands)
Node measurementGO
Local workerd measurementGO
GitHub-hosted reproducibilityGO (corrective run 38904588640 green; one earlier run failed for two proven infrastructure reasons, see Hosted evidence)
Candidate browser-size budgetSUPPORTED (as a fixed-cost candidate on this fixture family; per-island marginal cost not separable)
Candidate server-size budgetINSUFFICIENT EVIDENCE (the island dimension never exceeded it, but controller/provider scaling was not varied)
Production performance budgetsNOT QUALIFIED
Cloudflare edge cold-start performanceNOT MEASURED
Cloudflare Worker memory consumptionNOT MEASURED
Cancellation and cleanup under production loadNOT QUALIFIED
Real application adoptionNOT QUALIFIED
Strata 1.0NOT YET QUALIFIED

The island dimension

STRATA_PERF_ISLANDS (default 1; only 1 and 3 are accepted, anything else fails before any work). Both profiles use the same four arms (A native, B controllers, C Server Components, D both), the same packed tarballs outside the workspace, the same Nitro presets, the same compression and the same measurement policy. One shared fixture: islands-3/ only adds files to common/ and variants/; with one island the assembled application is byte-identical to PR #86's (a unit test pins this).

ProfileInteractive islandsPublic endpointsServer-owned data
1 islandproduct-availabilityGET /api/products/:idproduct repository, transitive helper, supplier canary
3 islandsproduct-availability, product-stock, product-delivery (three distinct components, own state, own request)the above plus /:id/stock and /:id/deliverythe above plus a fulfilment repository (server-only in C/D) whose private warehouse canary is consumed to choose the public region prop

In the Server Component arms the parent renders on the server and marks each island with [strataClient]; the serialized props are productId (all three) and region (delivery). Plain arms render the same components through the ordinary Angular graph. Native arms emit one Nitro file route per endpoint; controller arms register all handlers from one @Controller.

Reproduce

pnpm install --frozen-lockfile
pnpm --filter @strata-sc/www exec playwright install chromium   # once
STRATA_PERF_ISLANDS=1 STRATA_PERF_OUT=/tmp/perf-one   pnpm test:full-stack-performance   # about 6 min
STRATA_PERF_ISLANDS=3 STRATA_PERF_OUT=/tmp/perf-three pnpm test:full-stack-performance   # about 6 min
pnpm test:full-stack-performance:compare /tmp/perf-one/report.json /tmp/perf-three/report.json /tmp/perf-three
pnpm test:performance-tools            # 163 deterministic self-tests (94 before this work)

On GitHub: gh workflow run extended.yml --ref <branch> -f suite=performance -f islands=both (or one / three). both runs the two profiles one after the other in the same job on the same runner, then the comparison, and uploads one-island/, three-island/, comparison.md and a redacted environment.txt. The weekly schedule keeps the one-island baseline. The comparison refuses a pair that is not comparable (different package versions, dependency tuple, Node/wrangler/compatibility date, compression, presets or arm definitions, an incomplete report, an arm that failed correctness) and exits 1.

Environments

LocalGitHub-hosted
OS / archmacOS (darwin 25.6.0) / arm64Linux (ubuntu24 image 20261012.334.1) / X64
CPUApple M4, 10 coresAMD EPYC 7763, 4 vCPU, 16 GiB
Node / npm / pnpmv22.23.1 / 10.9.8 / 10.30.1v22.23.3 / 10.9.9 / 10.30.1
wrangler / compatibility date4.135.0 / 2026-09-214.135.0 / 2026-09-21
Workload3 repetitions x 200 requests per target and mode, warm-up 50, concurrency 8, 3 fresh builds per arm and presetsame
Measured commit52e0f86 (dirty tree: later test, workflow and documentation files only)9821d58 (clean tree)

Dependency tuple (identical for all eight arm-builds, 16 named packages, 819 locked non-Strata entries per arm, equal locally and hosted): Angular 22.1.7 (build 22.1.8), Analog 2.7.2 (nitro plugin 2.8.1), nitropack 2.13.4, h3 1.15.11, vite 8.3.0, TypeScript 6.0.3, rxjs 7.8.2, tslib 2.8.1.

Strata packages (private, packed with pnpm pack, never published): @strata-sc/core 0.2.2, @strata-sc/analog 0.2.2, @strata-sc/server-components 0.2.1. Tarball sizes are identical locally and hosted (8,117 / 19,875 / 77,293 B); the digests differ between machines (the tar/zlib output differs), so artifact identity is per environment:

Packagesha256, local (both profiles)sha256, hosted (both profiles)
core49434df58bb205fb1a69c07eb1b9b6cb334b2e1b7161343f1cbbc0167995bfcba0238aa624a8…
analog8f4ed0cd125fd960ac05f90467b7d9de7d31a639fb050997feb852d53b80c5a0d532688d0cc4…
server-components3fa949be0159c0dd917f0efdf31bd38e80977a2e84102079019baf88fd40001710e04d8d6184…

The full hosted digests are in the run's uploaded report.json files.

Results: size (MEASURED, local macOS, same run)

Arrows are one island → three islands for each arm. Browser JavaScript and the initial entry are identical for A/B (controllers add no browser bytes) and for C/D. Source maps are excluded from every figure.

MetricA nativeB controllersC Server ComponentsD full Strata
Browser JS raw267.4 KiB → 270.1 KiB267.4 KiB → 270.1 KiB271.7 KiB → 273.7 KiB271.7 KiB → 273.7 KiB
Browser JS gzip81.6 KiB → 82.1 KiB81.6 KiB → 82.1 KiB83.3 KiB → 83.6 KiB83.3 KiB → 83.6 KiB
Browser JS Brotli71.7 KiB → 72.1 KiB71.7 KiB → 72.1 KiB73.2 KiB → 73.4 KiB73.2 KiB → 73.4 KiB
Initial entry chunk raw263.6 KiB → 263.6 KiB263.6 KiB → 263.6 KiB264.5 KiB → 264.5 KiB264.5 KiB → 264.5 KiB
Lazy chunks raw3.8 KiB → 6.5 KiB3.8 KiB → 6.5 KiB7.2 KiB → 9.1 KiB7.2 KiB → 9.1 KiB
Browser JS files4 → 44 → 44 → 44 → 4
Node server graph raw1202.0 KiB → 1210.4 KiB1206.2 KiB → 1213.0 KiB1209.0 KiB → 1218.3 KiB1213.1 KiB → 1220.9 KiB
Node deployable bytes5568.6 KiB → 5596.8 KiB5582.3 KiB → 5607.0 KiB5602.3 KiB → 5629.0 KiB5616.0 KiB → 5639.1 KiB
Worker graph raw722.2 KiB → 727.7 KiB729.7 KiB → 733.1 KiB726.3 KiB → 732.3 KiB733.7 KiB → 737.6 KiB
Worker graph gzip201.3 KiB → 202.9 KiB204.1 KiB → 204.6 KiB202.8 KiB → 204.6 KiB205.6 KiB → 206.3 KiB
Pages deployable bytes3852.1 KiB → 3874.3 KiB3865.2 KiB → 3884.4 KiB3880.3 KiB → 3899.9 KiB3893.4 KiB → 3909.9 KiB

Chunk counts did not change (4 browser JS files in every arm and profile); the island components live in the lazy route chunk, so the initial entry grew by 0 B (A) / 8 B (C/D) when two islands were added.

Overheads and how they changed

MetricC−A (1)C−A (3)D−A (1)D−A (3)B−A (1 / 3)A grew byC−A changed byD−A changed by
Browser JS raw+4.3 KiB+3.6 KiB+4.3 KiB+3.6 KiB0 / 0+2.7 KiB−738 B−738 B
Browser JS gzip+1.7 KiB+1.5 KiB+1.7 KiB+1.5 KiB0 / 0+506 B−231 B−231 B
Browser JS Brotli+1.5 KiB+1.3 KiB+1.5 KiB+1.3 KiB0 / 0+444 B−230 B−230 B
Node server graph raw+7.0 KiB+7.9 KiB+11.1 KiB+10.4 KiB+4.1 / +2.6 KiB+8.4 KiB+943 B−683 B
Worker graph raw+4.0 KiB+4.6 KiB+11.5 KiB+9.9 KiB+7.5 / +5.3 KiB+5.5 KiB+540 B−1.6 KiB
Worker graph gzip+1.5 KiB+1.7 KiB+4.4 KiB+3.4 KiB+2.8 / +1.8 KiB+1.6 KiB+159 B−933 B

Interaction (D−B−C+A) is within ±15 B for every metric in both profiles (zero for the browser). The hosted run reproduces every number above: browser JavaScript raw and gzip bytes are byte-identical to local in all eight builds, server-graph raw bytes equal to local except ±2 B in the four controller (B) builds, server gzip bytes within 7 B, and the deployable total differs by 1.6 to 2.7 KiB (platform-specific traced files). Builds were deterministic across the three repetitions in every arm and preset, locally and hosted.

Byte attribution (what the numbers do and do not say)

Three different things grow when two islands are added, and only the last is Strata:

  1. Total application growth (A, which has no Strata): +2.7 KiB browser raw, +8.4 KiB Node graph, +5.5 KiB Worker graph. This is the extra public island code, the second and third island's routes and the inventory data.
  2. Additional public implementation code is part of (1) and is identical in all four arms by construction.
  3. Strata-attributable growth is the change in each overhead (the right-hand columns). It is small or negative: the Server Components overhead did not grow in the browser (−738 B raw, −231 B gzip).

Why a negative change is not "free islands": C−A is a net quantity. The plain arms ship the whole page implementation (including the fulfilment repository and the larger details template) to the browser, while the Server Component arms keep it server-only and ship only the boundary runtime and the islands. Adding the fulfilment module makes the server-only exclusion saving larger at three islands, which offsets the added boundary markup for two more islands. With these two profiles the boundary-runtime/protocol cost per island cannot be separated from the exclusion saving. The only per-island figure that can be stated is the observed net mean over the two added islands: Server Components browser −369 B raw / −115 B gzip per island, Node server +471 B, Worker raw +270 B, Worker gzip +80 B. These are not extrapolated beyond the two measured profiles.

The controllers' server overhead shrinks (B−A: Node +4.1 → +2.6 KiB, Worker +7.5 → +5.3 KiB raw) because the native arm emits one Nitro file route per endpoint: the Worker chunk list of A has 12 server chunks at three islands (one route chunk per endpoint plus a shared inventory chunk; 9 at one island) against 8 in B and D at either size, where the handlers sit inside the one controller in the existing server chunk. This is MEASURED (chunk lists in the report) and specific to file-routed Nitro versus a single controller; it does not predict the cost of many controllers, providers or validation.

Results: SSR document size and latency (MEASURED)

The SSR document grew with the page, which is an application effect and not a framework cost:

ArmDocument, 1 islandDocument, 3 islands
A, B (plain Angular)1,804 B2,331 B
C, D (Server Components)1,930 B2,738 B

Boundary markup adds 126 B at one island and 407 B at three (about 140 B per added island) on top of the plain document. Gzip of the document was not recorded.

Warm loopback latency, sequential mode, median of three repetitions, p50 / p90 / p95 in ms (concurrent-mode tables, min–max across repetitions, raw samples and status distributions are in report.json). Every measured response was semantically validated; there were no invalid responses and all statuses were 200 in both profiles and both runtimes.

Node, local macOS, one island

armAPI p50API p90API p95SSR p50SSR p90SSR p95
A1.79 ms2.29 ms2.36 ms7.15 ms8.40 ms8.97 ms
B1.66 ms2.15 ms2.30 ms7.27 ms8.68 ms9.02 ms
C1.61 ms1.98 ms2.09 ms7.10 ms8.45 ms9.00 ms
D1.71 ms2.37 ms2.46 ms7.23 ms8.86 ms9.27 ms

Node, local macOS, three islands

armAPI p50API p90API p95SSR p50SSR p90SSR p95
A1.69 ms2.26 ms2.33 ms6.92 ms8.28 ms8.86 ms
B1.71 ms2.37 ms2.47 ms7.33 ms8.84 ms9.39 ms
C1.64 ms2.28 ms2.36 ms7.16 ms8.60 ms9.09 ms
D1.69 ms2.34 ms2.41 ms6.78 ms8.51 ms8.74 ms

Local workerd, local macOS, one island

armAPI p50API p90API p95SSR p50SSR p90SSR p95
A2.66 ms3.04 ms3.42 ms10.7 ms13.2 ms14.0 ms
B2.70 ms3.27 ms3.57 ms10.9 ms13.3 ms14.3 ms
C2.63 ms3.33 ms4.11 ms11.5 ms13.8 ms14.5 ms
D2.52 ms3.02 ms3.33 ms11.2 ms13.6 ms14.1 ms

Local workerd, local macOS, three islands

armAPI p50API p90API p95SSR p50SSR p90SSR p95
A2.51 ms2.85 ms2.99 ms10.7 ms13.4 ms14.3 ms
B2.59 ms3.11 ms3.60 ms10.8 ms13.2 ms14.1 ms
C2.80 ms3.36 ms4.23 ms11.3 ms14.0 ms14.9 ms
D2.58 ms3.07 ms3.54 ms11.5 ms13.8 ms14.3 ms

On this host the Server Component arms C and D show an SSR p50 about 0.5 to 0.8 ms above A and B in local workerd in both profiles (for example 11.5 / 11.2 versus 10.7 / 10.9 ms at one island); the repetition ranges nearly touch, the offset does not grow from one island to three, and the hosted workerd run does not reproduce it consistently. It is recorded as an observation within noise; no latency is attributed to the framework. Node API and SSR differences are within the repetition spread everywhere. Local workerd does not qualify deployed latency, and cold start and Worker memory are NOT MEASURED.

GitHub-hosted evidence (MEASURED)

Run 38904588640, workflow_dispatch of the existing extended.yml with suite=performance islands=both on the branch at 9821d581e44286c8ccaab681821f06d50c905e25, ubuntu-latest (AMD EPYC 7763, 4 vCPU, 16 GiB), 27.6 minutes, conclusion success, contents: read, no secrets, no deployment. The comparison step classified the two reports as same-run (one runner, one commit, minutes apart): size and latency are directly comparable within the run; the local-versus-hosted comparison is cross-machine (sizes only).

An earlier run, 38901423753 at 433623439080c9c840c58921e54399a562563ee5, failed for two proven infrastructure reasons, both fixed and not repeated for a better number:

  1. The one-island report could not resolve git merge-base HEAD origin/main in a shallow clone (baseline.startingMainSha empty). The extended performance job now checks out with fetch-depth: 0. This defect existed in extended.yml as merged by #87 and affected every hosted performance run, one island included.
  2. In the three-island profile, the final local-workerd start of the very last repetition died with bind(): Address already in use (a pick-then-bind port race). Starting a workerd arm is now retried, on that error only, at most twice (unit-tested); no correctness failure is ever retried. The corrective run logged no collision.

Hosted size results equal the local ones as described above. Hosted latency, sequential, p50 / p90 / p95 in ms:

Node, hosted, one island

armAPI p50API p90API p95SSR p50SSR p90SSR p95
A0.55 ms0.60 ms0.65 ms5.40 ms6.62 ms7.20 ms
B0.55 ms0.65 ms0.68 ms5.19 ms6.25 ms6.78 ms
C0.52 ms0.68 ms0.83 ms5.34 ms6.32 ms6.91 ms
D0.57 ms0.68 ms0.69 ms5.19 ms6.21 ms6.67 ms

Node, hosted, three islands

armAPI p50API p90API p95SSR p50SSR p90SSR p95
A0.50 ms0.68 ms0.79 ms5.06 ms6.04 ms6.65 ms
B0.58 ms0.68 ms0.70 ms5.19 ms6.48 ms6.91 ms
C0.54 ms0.62 ms0.70 ms5.37 ms6.53 ms7.16 ms
D0.64 ms0.68 ms0.73 ms5.14 ms6.23 ms6.62 ms

Workerd, hosted, one island

armAPI p50API p90API p95SSR p50SSR p90SSR p95
A3.04 ms4.05 ms4.73 ms10.3 ms11.7 ms12.6 ms
B2.92 ms3.74 ms4.64 ms10.4 ms12.2 ms13.1 ms
C3.05 ms3.86 ms4.48 ms10.4 ms12.3 ms13.4 ms
D2.96 ms3.60 ms4.40 ms10.3 ms11.8 ms13.1 ms

Workerd, hosted, three islands

armAPI p50API p90API p95SSR p50SSR p90SSR p95
A2.77 ms3.69 ms4.52 ms9.99 ms11.6 ms12.3 ms
B2.91 ms3.66 ms4.62 ms9.47 ms11.2 ms12.1 ms
C2.80 ms3.47 ms4.67 ms10.6 ms11.9 ms12.9 ms
D2.92 ms3.76 ms4.72 ms10.0 ms12.0 ms12.7 ms

Hydration, interaction and confidentiality (MEASURED)

Real Chromium per arm and runtime (eight arm and runtime pairs per profile). Three islands: the SSR DOM of every island is tagged before any script runs; after hydration the original button, output and text nodes of each island are the same nodes (hydration reuse), each island appears exactly once, each is clicked once and issues exactly one request to its own endpoint, exactly that island's state changes (the other two keep their prior text), and there is no console error or warning, failed request or private canary in the hydrated DOM. One island: the PR #86 smoke, unchanged (it does not check failed requests or the hydrated DOM for canaries).

Confidentiality uses the existing synthetic markers plus the new fulfilment marker and warehouse canary: in C/D the Server Component implementation, product and fulfilment repositories, transitive helper and all canaries are absent from every browser chunk and source map, from the SSR HTML (including each boundary's serialized props) and every API response in both profiles, and from the hydrated DOM at three islands; the three client-island markers are present in every browser (positive controls). In A/B the plain repositories ship to the browser (the expected scanner control, not a leak); controller and Nitro handler markers are server-only in every arm; arm A contains no Strata specifier, dependency, lock entry, installed package or output.

Negative controls (all rejected)

The six one-island controls are unchanged. The three-island profile adds, each breaking exactly one island or canary: island-missing-from-ssr (one of three islands not rendered), island-boundary-identity-collision (two islands share one boundary identity in the real SSR document), island-click-issues-no-request (the delivery button's click never reaches its handler), island-state-not-updated (the stock request is aborted), browser-leak-warehouse (the fulfilment canary appended to a browser chunk). The existing missing-browser-functionality control also runs the three-island interaction check. 11 of 11 were rejected, locally and hosted, each for the intended reason (recorded in report.json). Unit tests additionally cover duplicate hydration, missing islands, wrong boundary identity, incomplete or mismatched comparison reports, delta arithmetic with zero, negative and missing values, invalid-response exclusion and canary detection.

Candidate budget assessment (PROPOSED values, nothing enforced)

Budget (from PR #86)One islandThree islandsAssessment
Controllers: +0 browser JS0 B0 BSupported, and an existing correctness guarantee that stays enforced separately
Server Components: ≤ +3 KiB gzip browser+1.7 KiB+1.5 KiBSupported as a fixed-cost figure; it did not grow with two more distinct islands. Not a per-island budget
Full stack, Node/Worker graph: ≤ +20 KiB raw+11.1 / +11.5 KiB+10.4 / +9.9 KiBWithin, with about half the headroom unused
Full stack, Worker graph: ≤ +8 KiB gzip+4.4 KiB+3.4 KiBWithin
  • One-island fixed-cost budget. The numbers are exactly PR #86's. They are a property of the fixture's small, mostly server-only page.
  • Total overhead of several distinct islands. Did not increase on any measured axis; the browser overhead decreased because server-only code was excluded. The one-island 3 KiB number was not applied to the three-island application as a rule; it is reported next to the measurement.
  • Per-island marginal cost. Not separable (net, confounded with server-only exclusion, two points). Not extrapolated.
  • Baseline size drift. Arm A moved from 267.4 to 270.1 KiB browser raw only because the application grew; PR #86's own A at one island is reproduced exactly.
  • Nondeterministic output. None observed: raw browser and server bytes were identical across three builds in every arm and preset, and server gzip varies by a few bytes between builds, which is why gzip is never used as a determinism fingerprint.
  • Server budget. Evidence is insufficient to call it scaling-safe because the dimension that dominates server cost (controller, provider and validation count, route count against file routes) was not varied; the island dimension adds handlers inside one controller only.

R13 is not closed by this measurement.

Methodology limitations

  • Two points (1 and 3) on a tiny synthetic fixture; no model, no per-island extrapolation. The islands are small and similar.
  • C−A is a net of boundary cost and server-only exclusion; the fixture's excluded server code is small and its ratio to island code is arbitrary.
  • One application shape, one Angular/Analog/Nitro tuple, Chromium only, loopback only, warm only. Local workerd is not Cloudflare.
  • Three repetitions; spreads of 0.2 to 1 ms mean latency differences under that are not resolvable. The hosted runner has 4 shared vCPUs.
  • Gzip of the SSR document, cold start, Worker memory, RSS under load, cancellation and cleanup, and CPU time were not measured.
  • The local measured tree was dirty with files that do not influence the measured output (unit tests, workflow edits, this document).
  • Package digests vary by machine; the dependency lock tuple does not.

Conclusions

  • The Server Components fixed cost is about +1.5 to +1.7 KiB gzip in the browser and did not grow from one to three distinct islands on this fixture; controllers add no browser JavaScript; the combined server/Worker overhead (about 10 to 11.5 KiB raw, 3.4 to 4.4 KiB gzip) did not grow either.
  • The benchmark, the comparison and the hosted workflow now exist for both profiles; hosted and local agree on size to within a few bytes.
  • The candidate browser budget is supported on this fixture; the server budget needs a controller/provider-count dimension before it can be called supported. No threshold is added to CI.
  • Recommended next increment: cancellation and cleanup under production load on Node and local workerd (client aborts, mid-stream disconnects and concurrent SSR plus controller traffic, with resource accounting), the remaining measurable piece of the open 1.0 resource item that does not require a Cloudflare deployment.

Source: docs/research/full-stack-performance-scaling.md

Generated by Entwine · Markdown remains the source of truth.