Performance model

Clank uses compilation and fine grained subscriptions instead of rerendering component trees. A component function runs once when mounted. Its TSX expressions become independent bindings that subscribe only to the signals they actually read

11 min read2,233 wordsClank 0.24.0
On this page

Clank uses compilation and fine-grained subscriptions instead of rerendering component trees. A component function runs once when mounted. Its TSX expressions become independent bindings that subscribe only to the signals they actually read.

Update guarantees

State changeDOM work
Text expression changesMutate the existing Text.data; node identity is retained
Attribute or property changesSet that property on the existing element
One style object changesDiff its property names and update changed/current entries
One classList object changesDiff its active tokens; add/remove only changed tokens
Conditional changes branchDispose and mount only the nodes inside its markers
Keyed item field changesUpdate bindings in that retained row
Keyed list reordersMove retained DOM ranges; do not recreate them
Keyed list insertion/removalMount/dispose only the affected keys

There is no virtual DOM tree, component rerender, or full-list diff of child VNodes on a normal signal update.

Compiler-created granularity

tsx
<article class={selected.value ? "selected" : ""}>
  <h2>{document.value.title}</h2>
  <p>{document.value.summary}</p>
</article>

This creates three subscriptions: one for class, one for the heading text, and one for the paragraph text. Changing summary does not execute the component, recreate the article, touch its class, or update the heading.

Event callbacks, ref, use, key, bind:*, and literal values are not wrapped in reactive effects.

Keyed lists

tsx
<For each={todos.value} by="id" fallback={<p>No todos</p>}>
  {(todo) => (
    <TodoRow todo={todo} />
  )}
</For>

Keys must be unique within the list. A property key such as by="id" or (item) => item.id is recommended for immutable records. Clank gives every retained object row a stable proxy with lazily created property signals. Replacing { id: "a", done: false } with { id: "a", done: true } preserves the row and notifies todo.done bindings without invalidating bindings that only read todo.id or todo.title.

Without by, object identity is the key. Primitive values use value plus index and are intended for simple display lists.

Batching

Signal writes are synchronous. A single write invalidates all dependent computed values before running effects, so an effect reached through several computed branches runs once with settled values. Wrap related writes in batch() so each dependent effect executes once after the final write. transaction() provides the same coalescing plus rollback on failure.

Hosted request routing

Managed ingress reads a fresh routing snapshot for each request. Verified custom domains are loaded in one query for all deployed projects, and installations with only local applications skip provider fleet reads. Domain revocations, reassignments, process replacement, and provider generation checks remain live; routing does not wait for a cache timeout.

The platform regression suite counts domain queries across multiple deployed applications and checks immediate host reassignment and revocation alongside the rollout and provider tests.

Measuring identity

The renderer regression suite asserts identity, not merely final HTML. It stores references to list elements and text nodes, edits and reorders immutable records, and verifies that the same objects remain mounted. This prevents a visually correct remount from being mistaken for a fine-grained update.

Run the performance invariants with:

sh
npm test -- tests/dom.test.mjs

CI performance budgets

npm run performance builds the framework and prints a JSON budget report. The same budget checks run in npm test and the complete release gate on supported Node 22.16, 24 and 26 runtimes. Budgets fail CI for excess work or bytes, including missing or non-finite measurements:

MeasurementMaximum
Effect executions for 1,000 writes through 100 computed branches1,000
Obsolete effect executions after 100 completed SSR requests0
Gzipped core / DOM / router / forms module bytes, Node 22/244,500 / 12,000 / 3,500 / 5,500
Gzipped core / DOM / router / forms module bytes, Node 265,250 / 12,000 / 3,500 / 6,000

The byte limits cover each compiled module separately, not its transitive imports or a complete application bundle. Compression uses gzip level 9. Wall-clock timings are reported for local comparison but do not fail CI on shared runners. The existing real ingress test separately enforces one domain query and zero fleet queries for a local-only installation.

Node 26 uses native type stripping, which preserves source layout and comments instead of printing transformed code. Its measured core/forms baselines are 5,088 and 5,746 compressed bytes. The additional byte allowance covers that compiler representation; runtime work limits and the published Node 24 build envelope remain unchanged.

Change a budget in scripts/performance-budgets.mjs only with a reviewed explanation of the additional work or payload. To reproduce ingress and HTTP transfer regressions alongside these budgets, run npm test -- tests/platform.test.mjs tests/node.test.mjs.

Regression evidence

The performance pass covers reactive propagation, keyed DOM updates, SSR lifecycle, HTTP streaming and static transfer, database/query caching, authentication and jobs, and local/provider deployment paths. The fixes below use deterministic work counts and real HTTP/database tests; they are not claims about production throughput or latency under an unmeasured workload.

Regression scenarioPrevious behaviorFixed behavior
One signal write through 100 computed branches to a shared effect100 effect executionsOne execution with settled values
Shared signal update after 100 completed SSR requests100 obsolete effects ranZero obsolete effects run
Revalidate an unchanged 1 MiB static assetFull body transferred againBodyless 304, no file stream
Build ingress routes for three local applicationsThree domain queries plus a fleet queryOne domain query, no fleet query
HEAD or client disconnect during response backpressureUnused body or locked reader remainedStream cancelled and reader released

The regressions live in tests/core.test.mjs, tests/ssr.test.mjs, tests/node.test.mjs, and tests/platform.test.mjs. npm run check also verifies DOM identity, authentication/ownership, live synchronization, durable jobs, provider fencing, rollout health gates, migration failure, and rollback through the packaged-release conformance journey. CI runs the supported Node 22.16 and Node 24 versions. Reproduce timing measurements on the deployment workload before drawing capacity conclusions from these narrower invariants.

Reproducible load and reliability profiles

The repository's scripts/load/README.md describes disposable HTTP A/B, authentication, managed ingress, live-connection, crash/replay, and large-list browser workloads. The generators create loopback servers and synthetic databases, record failed and dropped requests, and can compare a published package with a locally built revision. They do not target production.

The September 2026 load pass found two regressions: evicted live-query results lost their invalidation dependencies, and platform project visibility queries scanned unrelated tenants. Live subscriptions now retain dependency metadata independently of cached result payloads; project lists and dashboards use indexed ownership and membership lookups. The regression tests are tests/live-cache-eviction.test.mjs and tests/platform-tenant-lookups.test.mjs.

See the methodology and raw results for the tested limits. Request rates, account counts, open streams, and running application processes are different capacity measures. Repeat representative workloads on an isolated staging deployment with production-equivalent resources and external generators before setting launch capacity. Include password-login bursts, populated metric histories, and configured quotas.

Dashboard overviews aggregate only the current 24-hour metric summary in one query per project. They retain the detailed metrics endpoint's interval counts, minute peaks, request counts and latency histograms without building unused chart points or reading a discarded previous period. The detailed metrics view still includes those charts and comparisons.

Dashboard quota resolution reuses each account and workspace hierarchy only within the current synchronous response. Ten projects in one workspace require four quota-related database reads, down from 46. Every new request reads current billing entitlements, operator overrides, workspace membership, and session state; there is no quota cache shared between requests. The deterministic regressions are in tests/platform-dashboard-quotas.test.mjs.

SQLite document writes reuse the validated, canonical JSON encoding for both the current row and its revision history. An insert encodes the document once instead of twice. Changed patches and replacements encode the old and new document once each instead of four encodings in total. No-op detection, ownership, schema validation, version conflicts, and transaction rollback remain covered by tests/backend.test.mjs.

Server rendering handles synchronous subtrees without allocating a Promise for every node. renderToString() still returns a Promise, and asynchronous children preserve their output order and component cleanup. The SSR tests enforce constant Promise allocations as a static list grows, alongside escaping, hydration markers, context, and cleanup after failures.

The September 19 measurements compare these changes with the preceding security-audit revision. The report includes alternating A/B trials, raw results, workload limits, and reproduction commands.

The October performance pass extends those guarantees to large updates and workspaces:

  • Keyed rendering and hydration keep the longest retained sequence in place when order changes. Moving the last row to the front of a 1,000-row list moves one existing element instead of 999. Ordered edits skip sequence analysis; node identity, reactive indexes, and disposal stay intact.
  • Bulk live-query invalidation builds one temporary table/owner/record index per committed batch. A 64-record change with 1,000 live readers reads changed-record table metadata 192 times instead of 63,176 in the instrumented fixture, with identical notifications. Single-record writes use a direct matcher, and a backend without readers does not build the index. No stale cache is added.
  • Project lists join current workspace roles and explicit project permissions into the indexed visibility lookup. Dashboard domain and release summaries use two grouped queries restricted to the authorized project IDs. A 100-project fixture needs 112 database reads instead of 410; per-project metric aggregation remains and can dominate populated dashboards.

The regressions cover permission and session revocation, parent-project permission inheritance, tenant isolation after cache eviction, dynamic query dependencies, and rendering/hydration permutations. See the October 3 measurements for browser layout timings, HTTP load, live-delivery/recovery checks, controls, and reproduction.

Project lists and dashboards resolve each distinct workspace security policy once during synchronous response construction. Results expire when that response is built; subsequent requests read current policy, session assurance, and membership again. In a 100-project workspace with a configured policy, project-list database reads fall from 1,423 to 23 and dashboard reads from 1,530 to 130. The regression exercises policy tightening, passkey reauthentication, assurance changes, membership removal, and session revocation through the native controller. See the October 16 measurements for alternating local A/B trials, including populated metric histories.

Local search builds each replacement index in one pass before atomically swapping it in. Prefix search lazily sorts the current vocabulary once, then uses binary search to visit only the matching bounded expansion range. Term additions, removals, replacement, and snapshot restore invalidate that vocabulary; rejected replacements retain the prior index and its limits.

Public status updates use a page/ownership/sequence index, and subscriber notifications use a page/account/sequence index. Public history and sparse or empty inbox pages avoid scanning unrelated retained history. Existing catalogs gain these indexes when their controller reopens. See the October 16 follow-up measurements for cold/warm prefix controls, replacement work counts, actual query plans, and populated-history comparisons, including the additional index maintenance cost.

Complete application load budgets

clank workbench performance page.har budgets.json --baseline=before.har --json checks an actual browser navigation, including transitive modules, CSS, images, fonts, and other HTTP requests initiated through the page's load event. Export a HAR from a fresh browser context with cache disabled, after load completes. Use the same URL, fixture, viewport, and browser for the baseline and candidate. Disable service workers. HAR files can contain credentials: keep the raw captures private; the report excludes headers, cookies, bodies, URL credentials and queries. Resource paths remain visible, so use synthetic fixture URLs.

The budget file declares four nonnegative integer maxima:

json
{ "requests": 30, "bodyBytes": 300000, "javascriptBytes": 100000, "cssBytes": 30000 }

Body bytes are the HAR's encoded response body sizes, including compression actually served; they exclude HTTP headers. JavaScript and CSS are classified by response MIME type. Every request counts, including repeats and redirects. Query variants aggregate under the same origin/path. Resources and baseline byte deltas identify new, larger, and removed payloads. A script fetched indirectly counts exactly like a top-level script. This measures observed loading, not unused code or a static dependency graph; exercise lazy routes separately.

The command exits nonzero for exceeded budgets, unknown measurements, detected cache hits, failed responses, or a missing HTML document. Multiple-page captures require --page=<id>; the same ID selects both captures. Requests initiated after load are excluded, while requests started before load count in full. The capture must finish those responses before export. The report cannot detect requests omitted by the capture tool; keep network recording enabled throughout navigation. Add this command to an application's CI after its browser capture step.

Optional budget-file policies add load-time, exact-resource, and baseline-growth checks:

json
{
  "requests": 30, "bodyBytes": 300000, "javascriptBytes": 100000, "cssBytes": 30000,
  "maxLoadTimeMs": 2000,
  "resourceBudgets": [
    { "resource": "https://app.example.invalid/app.js", "maxBytes": 80000, "maxRequests": 1 }
  ],
  "regressionBudgets": { "javascriptBytes": { "absolute": 1000, "percent": 5 } }
}

A resource rule matches the exact canonical origin/path, without credentials, query or fragment. Missing resources measure zero. At most 100 unique resource rules are accepted. Regression policies require --baseline; absolute and percentage growth limits both apply when provided. Decreases pass a nonnegative growth limit. A percentage policy with a zero baseline permits no growth. Timing is the HAR page's completed onLoad, so compare equivalent environments.

The programmatic assessApplicationPerformance(har, budgets, options) API from @clank.run/framework/application-performance accepts the same optional policies in options. Reports include loadTimeMs, policyChecks, duplicateResources (repeated canonical resources), origins (request and byte totals by origin), and resourceTypes (HTML, JavaScript, CSS, image, font, media and other totals). Query variants can legitimately repeat; duplicate reporting is advisory unless a resource request budget forbids it. Every policy check participates in ok. No new summary retains request headers, credentials, query strings, fragments or bodies.

Untrusted captures are limited to 50,000 entries, 4,096 characters per URL and 8 MiB of selected URL characters. MIME strings are bounded to 256 characters and diagnostics to 100 distinct issues plus an omission notice. Entries predating navigation invalidate the capture instead of counting unrelated traffic. Unknown, cached and failed measurements continue to fail closed.