Hedronite · Dev Lesson · Polyglot-Dev/Web · 2026-05-31

Browser Telemetry for Release Engineering: JavaScript Performance Observation, TypeScript-Typed Wire Schemas, and HTML's Resource-Timing API

JavaScript Performance Observation, TypeScript-Typed Wire Schemas, and HTML's Resource-Timing API

Lesson Class: dev
Filed: 2026-05-31
Shelf: Polyglot-Dev/Web
Language: web

<!-- hal:authoritative:yaml -->

JavaScript Performance Observation, TypeScript-Typed Wire Schemas, and HTML's Resource-Timing API

§I — Frame

Today's Ops lesson named the release controller's watch loop: pause at each canary widen, read three metrics per version, decide hold or widen or rollback. The metrics come from the browser. This Dev lesson is about the browser-side telemetry layer that produces them.

Three web technologies contribute three layers to the pipeline. JavaScript captures the runtime measurements. TypeScript encodes the wire schemas the payloads ride on. HTML exposes the platform-native timing surfaces the JavaScript layer reads. The three operate as a single pipe; treating them as separate concerns hides how they compose, but separating them in this lesson makes the contribution of each visible.

The Sunday Web lesson convention from 2026-05-24 holds: each sub-section is dedicated to one of the three languages, with the unitary frame composing them. Read all three sub-sections in sequence; the value is in how they layer.

§II — JavaScript: Performance Observation

The browser exposes a Performance API that records every navigation, every resource fetch, every paint event, every long-running task. The API is observation-only — the events are recorded by the platform whether your code reads them or not. The JavaScript layer's job is to subscribe to the events that matter for the release-engineering watch loop and route them to the telemetry endpoint.

The primitive is PerformanceObserver. It takes a callback and an entry-type filter; the callback fires whenever an entry of the specified type lands. Three entry types carry most of the release-engineering signal: largest-contentful-paint for the paint-completion time of the largest above-the-fold element, long-animation-frame for any frame that took longer than 50ms to compute, and event for input latency on user interactions.

const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        sendTelemetry({
            type: entry.entryType,
            name: entry.name,
            startTime: entry.startTime,
            duration: entry.duration,
            version: window.__FRONTEND_VERSION__,
            sessionId: getSessionId(),
            timestamp: Date.now(),
        });
    }
});

observer.observe({ entryTypes: ['largest-contentful-paint', 'long-animation-frame', 'event'] });

The observer fires once per matching entry. The callback's job is fast routing — gather the entry's measurements, attach the version and session context the release controller needs, push to the telemetry channel. No computation should happen in the observer callback itself; if you need derived metrics, compute them downstream in the telemetry pipeline.

The version tag (window.__FRONTEND_VERSION__) is the integration point with the Ops lesson's canary configuration. The build pipeline injects the version into the HTML at deploy time; every measurement carries the version through every layer of the pipeline. The release controller's per-version dashboards depend on this tag being present and correct on every event.

The transport from browser to telemetry endpoint is the second JavaScript concern. Sending a fetch per event scales badly. The standard pattern is batching with navigator.sendBeacon for unloads and a periodic fetch for the steady-state.

const buffer = [];
const FLUSH_INTERVAL_MS = 5000;
const FLUSH_SIZE = 50;

function sendTelemetry(payload) {
    buffer.push(payload);
    if (buffer.length >= FLUSH_SIZE) flush();
}

function flush() {
    if (buffer.length === 0) return;
    const batch = buffer.splice(0, buffer.length);
    fetch('/telemetry', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ events: batch }),
        keepalive: true,
    }).catch((err) => {
        console.warn('telemetry flush failed', err);
    });
}

setInterval(flush, FLUSH_INTERVAL_MS);

window.addEventListener('pagehide', () => {
    if (buffer.length > 0) {
        navigator.sendBeacon('/telemetry', JSON.stringify({ events: buffer }));
    }
});

The keepalive: true flag is the discipline that prevents the fetch from being canceled when the user navigates away. The pagehide handler with sendBeacon is the fallback for the same case; sendBeacon is the platform's only guarantee that a final telemetry batch survives a navigation. Both are required for clean measurement across user-driven session ends.

§III — TypeScript: Typed Wire Schemas

The telemetry payload is a wire format. Both ends of the wire — the browser sender and the telemetry-receiving service — must agree on the shape. When they disagree, the receiving service either drops malformed payloads silently or accepts them and produces incorrect metrics. TypeScript's type system lets the wire schema be a single source of truth, generated into both browser code and server code, with compile-time enforcement that the two never drift.

The schema is a discriminated union, one variant per event type:

type TelemetryEvent =
    | LcpEvent
    | LongAnimationFrameEvent
    | InputLatencyEvent
    | ReconnectEvent
    | ErrorEvent;

interface BaseEvent {
    version: string;
    sessionId: string;
    timestamp: number;
}

interface LcpEvent extends BaseEvent {
    type: 'lcp';
    startTime: number;
    elementSelector: string;
}

interface LongAnimationFrameEvent extends BaseEvent {
    type: 'long-animation-frame';
    duration: number;
    blockingScripts: string[];
}

interface InputLatencyEvent extends BaseEvent {
    type: 'input-latency';
    interactionType: 'click' | 'keydown' | 'pointerdown';
    duration: number;
}

interface ReconnectEvent extends BaseEvent {
    type: 'reconnect';
    reason: 'tls-failure' | 'idle-timeout' | 'server-disconnect' | 'unknown';
    sinceLastReconnectMs: number;
}

interface ErrorEvent extends BaseEvent {
    type: 'error';
    errorClass: string;
    message: string;
    stack: string;
}

The discriminated union is the discipline. Every event has a type field. TypeScript's type narrowing means that code branching on the type field gets the correct event shape inside each branch without runtime checks. A function that receives a TelemetryEvent and switches on the type field is exhaustively type-checked at compile time; if a new event type is added to the union without being handled in the switch, the compiler fails the build.

The same type definition is shared between browser and server. The build pipeline produces a single types package that both consume. Schema evolution discipline follows: adding a new event type is backward-compatible; renaming or removing a field requires a version bump and a deprecation cycle.

The receiving service uses the same types to validate incoming payloads. A library like zod or io-ts can generate runtime validators from TypeScript types; the validator rejects payloads that do not match the schema. The release controller can rely on every event in its dashboards being well-formed; malformed events never made it past the receiving service's validation gate.

§IV — HTML: Native Timing Surfaces

The browser's Performance API rides on platform-exposed timing surfaces. Three of them are surfaced directly to HTML at the document level and feed the JavaScript layer's measurements.

Resource Timing records every network resource the page loads — every script, every stylesheet, every image, every fetch. Each entry carries the URL, the start time, the response time, the size, and the cache-status. The release-engineering value: per-version comparison of asset load times surfaces regressions in build-size or CDN performance.

<!DOCTYPE html>
<html>
<head>
    <link rel="preconnect" href="https://api.example.com">
    <link rel="preload" as="script" href="/static/v2026.05.31/app.js">
    <script src="/static/v2026.05.31/app.js" defer></script>
</head>
<body>
    <main id="market-data-container"></main>
</body>
</html>

The <link rel="preconnect"> warms the TLS connection to the API endpoint before the JavaScript fetch needs it; the resource-timing entry for the subsequent fetch records the warm-connection time, which is the operator's signal that the preconnect is doing its job. The <link rel="preload"> hints to the browser to start fetching the app bundle in parallel with HTML parsing; the resource-timing entry for the script records whether the preload was warm-hit at execution time.

Server Timing records the server-side breakdown of response time, delivered through the Server-Timing HTTP header. The server emits this header with named timing components; the browser exposes them through the Performance API alongside the network-side measurements. The release-engineering value: end-to-end attribution of latency across server, network, and browser tiers, all in one pipeline.

HTTP/1.1 200 OK
Content-Type: application/json
Server-Timing: db;dur=23, cache;dur=4, render;dur=12

The JavaScript layer reads these alongside the browser-side timing through performance.getEntriesByType('navigation')[0].serverTiming. The release controller's per-version dashboards can break down "where the time went" without requiring custom instrumentation at each tier — the Server-Timing protocol carries the breakdown end-to-end.

Navigation Timing records the page-load lifecycle — navigation start, DNS lookup, TCP connect, TLS handshake, time to first byte, DOM content loaded, full load complete. Every page load produces one Navigation Timing entry that captures the full lifecycle.

The release-engineering value: when a new version regresses any of these phases, the regression is visible per-version in the navigation-timing dashboard. The 2026-05-24 lesson named the latency budget as a tiered allocation; the navigation-timing breakdown is the per-phase audit against the allocation.

§V — Connection to Today's Ops Lesson

The Ops lesson named the release controller's watch loop: per-version error rate, paint latency, reconnection rate. Each of the three metrics is sourced from this Dev lesson's telemetry pipeline.

Per-version error rate comes from the ErrorEvent variant of the TypeScript schema, captured by a global window.addEventListener('error', ...) and 'unhandledrejection' handler in the JavaScript layer, tagged with the version from the HTML's injected build identifier.

Per-version paint latency comes from the LcpEvent variant, captured by the PerformanceObserver subscribed to 'largest-contentful-paint' entries.

Per-version reconnection rate comes from the ReconnectEvent variant, emitted by the WebSocket-connection layer whenever it re-establishes a connection. Each reconnection is tagged with the reason, allowing the release controller to distinguish TLS failures (which the 2026-05-31 release scenario surfaced as the canary-pause trigger) from idle-timeouts (which are background noise) from server-disconnects (which signal server-side issues, not version regressions).

The three metrics, separated by version, are what the operator reads in the canary watch window. The browser-side telemetry pipeline of this Dev lesson is what produces them.

Today's Cert lesson covers CCA-F Domain One (Agentic Architecture). The connection extends to agentic systems that surface telemetry from their own browser frontends; the same Performance Observer + TypeScript schema + HTML timing surface pattern applies symmetrically.

§VI — Prior-Lesson Reach

The 2026-05-24 Streaming Market Data lesson named the JS+TS+HTML three-layer pattern for streaming data into the browser. This lesson generalizes the same composition to the telemetry-out direction. The pattern's spine is the same: HTML exposes the native primitive, TS encodes the wire schema, JS orchestrates the runtime. The two lessons are mirror images of each other across the data-direction axis.

The 2026-05-26 Rust Ownership for Secret Handling lesson named the discipline of types that cannot leak. The TypeScript wire schema of this lesson is a softer version of the same idea — types that constrain the wire payload and catch malformed events at compile time. TypeScript's structural typing is not as strict as Rust's ownership types, but the discipline of using the type system to prevent error classes the runtime would otherwise carry is the same.

§VII — Closing

The three layers compose into one pipe. The release controller reads from the pipe's output. The three layers separated give each language its native contribution; the pipe unified gives the operator the signal they need to make the hold-widen-rollback decision in the canary watch loop.

Sunday's web stack discipline holds: each language contributes its native primitive, no language reaches outside its layer, the composition is what does the work.

Read the Ops lesson before this one. Read the Cert lesson after.