Five PerformanceObserver Patterns That Catch What Logging Misses
🎯 Web Performance API Collection: 5 Lesser-Known Uses of Performance Observer
Performance Monitoring | Browser APIs | You thought the Performance API was just
performance.now()?PerformanceObservercan listen for LCP, FMP, long tasks, memory leaks... and even capture "who is secretly running expensive synchronous operations" — all without blocking the main thread.
The Problem Scenario
An online project occasionally stutters, but the issue can't be reproduced in DevTools. Instrumented logs also reveal nothing — because you can only see the result (the page is slow), not the cause (what is occupying the main thread).
// Traditional instrumentation
const start = performance.now()
doHeavyWork()
console.log(`Duration: ${performance.now() - start}ms`)
This approach has two fatal flaws:
- High invasiveness — requires modifying business code.
- Cannot catch sporadic issues — you don't know when to instrument.
The Solution: PerformanceObserver
PerformanceObserver is a performance API based on the observer pattern. It can subscribe to multiple performance event types and execute callbacks asynchronously when events occur, with zero invasiveness.
1️⃣ Capturing All Long Tasks
Which functions monopolize the main thread for more than 50ms? Catch them directly:
const observer = new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
console.warn(`⚠️ Long Task ${entry.duration.toFixed(0)}ms`, {
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
// attribution contains specific function info (Chrome)
culprit: entry.attribution?.[0]?.toJSON()
})
}
})
observer.observe({ type: 'longtask', buffered: true })
Example output:
⚠️ Long Task 312ms { name: "self", startTime: 4820.3, duration: 312.1,
culprit: { containerName: "window", containerId: "", containerSrc: "" } }
💡 Combined with
attribution, you can pinpoint which iframe or Worker triggered the long task.
2️⃣ Monitoring LCP (Largest Contentful Paint)
LCP is a Core Web Vitals metric. Using PerformanceObserver, you can know exactly which element is the LCP element:
const lcpObserver = new PerformanceObserver(list => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
console.log(`🎨 LCP Element: ${lastEntry.element || lastEntry.url}`, {
renderTime: lastEntry.renderTime,
loadTime: lastEntry.loadTime,
size: lastEntry.size,
id: lastEntry.id,
url: lastEntry.url
})
})
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true })
Real-world scenario: Discovered the LCP element was a background-image. Investigation revealed the image lacked a preload. Adding <link rel="preload"> reduced LCP from 4.2s to 1.8s.
3️⃣ Automatically Tracking Resource Load Timings
Want to know why a specific API request is slow? No need to modify fetch code:
const resourceObserver = new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
if (entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') {
console.log(`🌐 ${entry.name}`, {
dns: `${entry.domainLookupEnd - entry.domainLookupStart}ms`,
tcp: `${entry.connectEnd - entry.connectStart}ms`,
tls: `${entry.secureConnectionStart ? entry.connectEnd - entry.secureConnectionStart : 0}ms`,
ttfb: `${entry.responseStart - entry.requestStart}ms`,
download: `${entry.responseEnd - entry.responseStart}ms`,
total: `${entry.duration}ms`
})
}
})
})
resourceObserver.observe({ type: 'resource', buffered: true })
Example output:
🌐 https://api.example.com/users
{ dns: "2ms", tcp: "15ms", tls: "28ms", ttfb: "320ms", download: "45ms", total: "410ms" }
At a glance, the bottleneck is TTFB (slow server response), not the network.
4️⃣ Monitoring First Paint / First Contentful Paint
const paintObserver = new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
console.log(`🎨 ${entry.name}: ${entry.startTime}ms`)
})
})
paintObserver.observe({ type: 'paint', buffered: true })
// 🎨 first-paint: 435ms
// 🎨 first-contentful-paint: 435ms
5️⃣ Capturing Layout Shifts (CLS Metric)
const clsObserver = new PerformanceObserver(list => {
let cls = 0
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) {
cls += entry.value
console.warn(`💥 Layout Shift: ${entry.value.toFixed(3)}`, {
source: entry.sources?.[0]?.node || 'unknown'
})
}
})
console.log(`📊 Cumulative CLS: ${cls.toFixed(3)}`)
})
clsObserver.observe({ type: 'layout-shift', buffered: true })
Caught a dynamically inserted ad slot causing page jitter. Adding a min-height placeholder reduced CLS from 0.35 to 0.05.
Key Takeaways Summary
| API | Monitoring Target | Key Fields |
|---|---|---|
longtask |
Main thread blocking >50ms | duration, attribution |
largest-contentful-paint |
LCP performance | element, renderTime |
resource |
Resource/request timing | initiatorType, duration, phase timings |
paint |
FP / FCP | startTime |
layout-shift |
CLS layout shifts | value, sources |
⚠️ Important Notes
buffered: truecan retrieve performance entries from before registration — this is critical for first-screen analysis.- Remember to
disconnect()when done to avoid performance overhead. - Not all browsers support all types; wrap in
try-catchfor production. - Combined with
PerformanceServerTiming, you can retrieve custom metrics sent from the server.
In one sentence:
PerformanceObserveris the most elegant "non-invasive performance monitoring solution" — by the time users report lag, the data is already captured.