name: perf description: Speed and memory performance rules for Rust crates, webui-framework, and webui-router.
Performance
WebUI's value proposition is speed and low memory usage. Every change to core Rust crates, @microsoft/webui-framework, or @microsoft/webui-router must be evaluated through two lenses: throughput (how fast) and memory (how little).
Server memory is not cheap. Client memory is not unlimited. Every allocation that can be avoided is a win on both sides.
Use this skill when modifying any performance-sensitive code across the stack.
Rust - speed rules
These apply to webui-handler, webui-state, webui-expressions, webui-parser, webui-protocol, and webui-ffi.
- No
format!()in writer output. Use sequentialwriter.write()calls.format!allocates a temporaryStringevery invocation. - No
.to_string()onCow. WriteCow<str>directly to avoid defeating zero-copy. - No
collect::<Vec<_>>()on splits. Iteratepath.split('.')directly. Collecting allocates aVecfor sequential access. - No redundant scans. Use
first_part.len() == path.len()instead ofpath.contains('.'). - No
String::from(ch)in escape loops. Usech.encode_utf8(&mut buf)with a[u8; 4]stack buffer, or batch contiguous safe chars into a single write. - No
format!in hex parsing. Use direct arithmetic ((hi_nibble << 4) | lo_nibble) instead ofu8::from_str_radix(&format!(...)). - No per-request template re-parsing. Pre-parse at protocol load time and reuse.
- No silent
unwrap_ordefaults. Ifbinding_stack.pop()returnsNone, that's a protocol error - propagate it, don't mask it.
Rust - memory rules
- No cloning large state trees. Use
evaluate_with_resolverwith a closure. Cloning duplicates the entire JSON tree in memory. - No cloning HashMaps for scope. Save/restore only the overwritten key on loop iteration. A HashMap clone copies every entry.
- No cloning
Stringvalues for read-only access. Uses.as_str()forValue::Stringbranches; only create owned strings forNumber/Boolvia a scratch buffer orCow. - Pre-allocate buffers. Use
Vec::with_capacity/String::with_capacitywhen size is known or estimable. For HTML output, 4096 bytes is a reasonable starting point. - Prefer
&strand slices over owned types. Pass by reference when the callee only reads. Move clone decisions to the caller. - Use
Cow<'_, str>when a value is sometimes borrowed, sometimes owned. Avoids unconditional allocation. - No deep-cloning protocol or state per request. Use
Arc<T>with clone-on-write or snapshot swapping. - Cap memory for untrusted inputs. File reads during discovery must have size limits. A 100MB HTML file should not cause OOM.
TypeScript - @microsoft/webui-framework rules
These apply to packages/webui-framework (the client-side Web Component runtime).
Speed
- Single-pass hydration. The framework walks the DOM once to connect all bindings. No multi-pass scanning.
- Path-indexed targeted updates. When an
@observablechanges, only bindings referencing that property are visited - not the entire template. - DOM cloning over innerHTML. Use
cloneNode(true)from cached template fragments. Never useinnerHTMLfor component creation. - Delegated events. One listener per event type on the shadow root, not one closure per element. Reduces listener count by orders of magnitude.
- Microtask coalescing. Multiple property changes within the same synchronous block batch into a single DOM update via
queueMicrotask. - Cursor-based repeat reconciliation.
<for>block updates use a diff algorithm that only callsinsertBeforeon nodes that actually moved. Append/prepend/remove are O(1). - No
for..inon objects. UseObject.keys()with an indexedforloop — faster and prototype-safe without needingObject.hasOwn. Applies tosetState,setInitialState, and any code iterating user-provided objects.
Memory
- No framework in the GC. Minimize object allocations during reactive updates. Reuse binding objects, don't recreate them.
- Template cache is
WeakMap-keyed. Parsed template DOMs are cached per metadata object. When metadata is released (e.g., viaRouter.gc()), the cache entry becomes GC-eligible. - No per-update array allocations. Avoid
.filter(),.map(),.slice()in the update hot path. Use index-based iteration. - Strip SSR markers after hydration. Comment nodes used as markers are removed from the DOM once wiring is complete - they don't persist as memory overhead.
- Scope frames are stack-allocated.
<for>loop item variables use a linked-list scope chain, not cloned Maps or Objects.
TypeScript - @microsoft/webui-router rules
These apply to packages/webui-router (the client-side SPA router).
Speed
- Server does route matching. The client does not re-match routes. The server returns the matched
chainarray; the client diffs old vs new and mounts only changed components. - Lazy loading via dynamic import. Route component JS is fetched only on first navigation to that route.
- Chain diffing, not full remount. Navigating between sibling routes preserves parent components. Only the changed level is remounted.
- No
for..inon objects. UseObject.keys()with an indexedforloop.for..inwalks the prototype chain (slow) and requires anObject.hasOwnguard to be safe —Object.keysis both faster and prototype-safe in one call:// ✗ Bad: slow, prototype-unsafe without guard for (const key in obj) { ... } // ✗ Still bad: correct but slower than Object.keys for (const key in obj) { if (Object.hasOwn(obj, key)) { ... } } // ✓ Good: fast, prototype-safe, no guard needed const keys = Object.keys(obj); for (let i = 0; i < keys.length; i++) { ... }
Memory
- Release unused templates.
Router.gc()clears cached component templates for routes the user hasn't visited recently. Active route components are never released. - Inventory bitmask prevents duplicate downloads. The server tracks which component templates the client already has via a bitmask. Re-navigation never re-sends templates.
- Minimal state per navigation. Route-scoped state means the JSON partial contains only what the active route needs, not the full app state.
Measuring
Rust benchmarks
cargo bench -p microsoft-webui --bench contact_book_bench # full run
cargo bench -p microsoft-webui --bench contact_book_bench -- --test # quick validation
cargo xtask bench all # all crates
Compare Render/1000 P50 before and after. Verify output Bytes is unchanged (same HTML = correct behavior).
Client performance
window.addEventListener('webui:hydration-complete', () => {
for (const entry of performance.getEntriesByType('measure')) {
if (entry.name.startsWith('webui:hydrate:')) {
console.log(`${entry.name}: ${entry.duration.toFixed(1)}ms`);
}
}
});
What to report
When making a performance-related change, report:
- Before/after benchmark numbers (P50 latency, throughput)
- Allocation count delta if measurable
- Output size unchanged (proves correctness)
- Memory profile for memory-related changes (heap snapshots, RSS delta)