From harness-claude
Measures web performance with Browser Performance API: PerformanceObserver for Web Vitals/RUM, Navigation/Resource Timing for load metrics, User Timing for custom business durations.
npx claudepluginhub intense-visions/harness-engineering --plugin harness-claudeThis skill uses the workspace's default tool permissions.
> Master the browser Performance API — PerformanceObserver, Navigation Timing, Resource Timing, User Timing, Server Timing, and Element Timing — to build custom performance measurement, monitoring, and alerting into any web application.
Implements Real User Monitoring (RUM) for web apps to track Core Web Vitals, page loads, and custom events using Google Analytics, Datadog, or New Relic.
Guides performance optimization for web apps: measure baselines with Lighthouse/DevTools/web-vitals, identify bottlenecks, fix issues, verify improvements targeting Core Web Vitals and load times.
Conducts web performance audits with Core Web Vitals (LCP, FID, CLS, INP), Lighthouse automation, bottleneck identification, and optimization recommendations for page load times and UX issues.
Share bugs, ideas, or general feedback.
Master the browser Performance API — PerformanceObserver, Navigation Timing, Resource Timing, User Timing, Server Timing, and Element Timing — to build custom performance measurement, monitoring, and alerting into any web application.
PerformanceObserver is needed to collect Web Vitals data (LCP, CLS, INP) in productionperformance.now() is preferred over Date.now() for sub-millisecond precisionbuffered: true flag is needed to capture entries that occurred before observer registrationperformance.clearMarks() and performance.clearMeasures() to prevent memory accumulationUse PerformanceObserver (not getEntriesByType). The observer pattern is more reliable — it captures entries as they occur and supports the buffered flag to retrieve entries that happened before registration:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.entryType, entry.name, entry.startTime, entry.duration);
}
});
// buffered: true captures entries that occurred before this line
observer.observe({ type: 'resource', buffered: true });
Measure custom business metrics with User Timing:
// Mark the start of an operation
performance.mark('checkout-start');
// ... checkout logic ...
// Mark the end
performance.mark('checkout-end');
// Measure the duration between marks
const measure = performance.measure('checkout-duration', 'checkout-start', 'checkout-end');
console.log('Checkout took:', measure.duration, 'ms');
// Measure with metadata (User Timing Level 3)
performance.measure('api-call', {
start: 'api-start',
end: 'api-end',
detail: { endpoint: '/api/cart', method: 'POST' },
});
Extract Navigation Timing data:
const nav = performance.getEntriesByType('navigation')[0];
const metrics = {
// DNS lookup
dns: nav.domainLookupEnd - nav.domainLookupStart,
// TCP connection
tcp: nav.connectEnd - nav.connectStart,
// TLS negotiation
tls: nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0,
// Time to First Byte
ttfb: nav.responseStart - nav.requestStart,
// HTML download
download: nav.responseEnd - nav.responseStart,
// DOM parsing
domParsing: nav.domInteractive - nav.responseEnd,
// DOM content loaded
domContentLoaded: nav.domContentLoadedEventEnd - nav.domContentLoadedEventStart,
// Total page load
pageLoad: nav.loadEventEnd - nav.startTime,
};
Analyze resource loading with Resource Timing:
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// transferSize = bytes over the network (0 means cache hit)
// encodedBodySize = compressed size
// decodedBodySize = uncompressed size
if (entry.transferSize === 0) {
console.log('Cache hit:', entry.name);
} else {
const compressionRatio = entry.decodedBodySize / entry.encodedBodySize;
console.log(
'Resource:',
entry.name,
'Size:',
entry.transferSize,
'Compression:',
compressionRatio.toFixed(2)
);
}
}
});
observer.observe({ type: 'resource', buffered: true });
Read Server Timing from response headers:
// Server sends: Server-Timing: db;dur=53, cache;desc="Cache Read";dur=2, app;dur=120
const resources = performance.getEntriesByType('resource');
for (const resource of resources) {
if (resource.serverTiming) {
for (const timing of resource.serverTiming) {
console.log(`${timing.name}: ${timing.duration}ms (${timing.description})`);
// db: 53ms, cache: 2ms (Cache Read), app: 120ms
}
}
}
Use Element Timing for specific element render time:
<!-- Add elementtiming attribute to elements you want to measure -->
<img src="/hero.jpg" elementtiming="hero-image" alt="Hero" />
<h1 elementtiming="main-heading">Page Title</h1>
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.identifier, 'rendered at:', entry.startTime, 'ms');
}
});
observer.observe({ type: 'element', buffered: true });
Use performance.now() for high-resolution timing:
// performance.now() — microsecond precision, monotonic (not affected by clock adjustments)
const start = performance.now();
doExpensiveWork();
const elapsed = performance.now() - start;
console.log(`Elapsed: ${elapsed.toFixed(3)}ms`);
// Date.now() — millisecond precision, wall clock (affected by NTP, manual adjustments)
// DO NOT use Date.now() for performance measurement
| Entry Type | Source | Key Properties |
|---|---|---|
navigation | Page load | responseStart, domInteractive, loadEventEnd |
resource | Each network request | transferSize, encodedBodySize, serverTiming |
mark | performance.mark() | name, startTime |
measure | performance.measure() | name, duration, detail |
longtask | Tasks >50ms | duration, attribution |
event | User interactions | processingStart, processingEnd, duration |
largest-contentful-paint | LCP candidate | element, url, size, startTime |
layout-shift | Visual shifts | value, hadRecentInput, sources |
element | Elements with elementtiming | identifier, startTime, element |
paint | FP and FCP | name (first-paint or first-contentful-paint) |
Etsy measures time-to-render for each product card on search results pages. They place performance.mark('card-render-start') before hydrating each card and performance.mark('card-render-end') after:
function renderProductCard(card, index) {
performance.mark(`card-${index}-start`);
hydrateCard(card);
performance.mark(`card-${index}-end`);
performance.measure(`card-${index}-render`, `card-${index}-start`, `card-${index}-end`);
}
// Aggregate and send to analytics
const measures = performance
.getEntriesByType('measure')
.filter((m) => m.name.includes('card'))
.map((m) => m.duration);
const p50 = percentile(measures, 50);
const p95 = percentile(measures, 95);
sendToGrafana({ cardRenderP50: p50, cardRenderP95: p95 });
Cloudflare uses Server-Timing headers to pass backend timing breakdowns through to the browser. The edge server adds headers: Server-Timing: edge;dur=2, origin;dur=150, db;dur=53. The frontend Performance API reads these without any custom telemetry:
const pageNav = performance.getEntriesByType('navigation')[0];
if (pageNav.serverTiming) {
const timingMap = Object.fromEntries(pageNav.serverTiming.map((t) => [t.name, t.duration]));
// { edge: 2, origin: 150, db: 53 }
dashboard.update(timingMap);
}
This gives frontend dashboards full-stack timing visibility: TTFB = 200ms, of which edge processing = 2ms, origin fetch = 150ms, database = 53ms.
By default, Resource Timing entries for cross-origin resources have zero values for detailed timing (DNS, TCP, TLS, request/response). This is a privacy protection. To enable full timing:
Timing-Allow-Origin: * (or the specific origin) in response headersstartTime, duration, transferSize (sometimes 0), and encodedBodySize (0) are availablePolling performance.getEntriesByType() instead of using PerformanceObserver. Polling wastes CPU, misses entries between polls, and does not capture entries that occur after the poll. PerformanceObserver fires exactly when entries are available.
Forgetting buffered: true on observer. Without buffered: true, entries that occurred before observer registration are missed. For LCP, CLS, and navigation timing, these entries always occur before your observer code runs.
Not clearing marks and measures in SPAs. In long-lived single-page applications, marks and measures accumulate in the performance buffer. Without performance.clearMarks() and performance.clearMeasures(), memory grows linearly with user actions. Clear after sending data to analytics.
Using Date.now() for performance measurement. Date.now() has 1ms resolution, is affected by system clock adjustments (NTP sync, manual changes), and can go backward. performance.now() has 5-microsecond resolution (subject to cross-origin isolation), is monotonic, and is unaffected by clock adjustments.
Measuring in non-isolated contexts expecting full precision. Without cross-origin isolation (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp), performance.now() is rounded to 100 microseconds (not 5 microseconds). For sub-millisecond measurements, enable cross-origin isolation.
buffered: true for all metric collection.