The PerformanceObserver API provides the ability to monitor performance metrics. This article covers its usage and practical applications.

Basic Concepts

Creating an Observer

// Create observer
const observer = new PerformanceObserver((list, observer) => {
  const entries = list.getEntries();

  entries.forEach(entry => {
    console.log(entry.name, entry.entryType, entry.duration);
  });
});

// Start observing
observer.observe({ entryTypes: ['resource', 'paint', 'longtask'] });

// Disconnect
observer.disconnect();

// Get recorded entries
const records = observer.takeRecords();

Supported Entry Types

// Get supported types
const supportedTypes = PerformanceObserver.supportedEntryTypes;
console.log(supportedTypes);

// Common types:
// - 'navigation': Page navigation
// - 'resource': Resource loading
// - 'paint': Paint timing
// - 'mark': Custom marks
// - 'measure': Custom measurements
// - 'longtask': Long tasks
// - 'element': Element timing
// - 'first-input': First input
// - 'largest-contentful-paint': LCP
// - 'layout-shift': Layout shifts

// Observe by type
observer.observe({ type: 'resource', buffered: true });

// buffered: true includes entries before observation started

PerformanceEntry

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    // Common properties
    console.log(entry.name);        // Entry name
    console.log(entry.entryType);   // Entry type
    console.log(entry.startTime);   // Start time
    console.log(entry.duration);    // Duration

    // Convert to JSON
    console.log(entry.toJSON());
  });
});

Core Web Vitals

Largest Contentful Paint (LCP)

class LCPObserver {
  constructor(callback) {
    this.callback = callback;
    this.lastLCP = null;

    this.observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();

      // LCP may fire multiple times, take the last one
      entries.forEach(entry => {
        this.lastLCP = {
          value: entry.startTime,
          element: entry.element,
          size: entry.size,
          url: entry.url,
          id: entry.id
        };
      });
    });

    try {
      this.observer.observe({ type: 'largest-contentful-paint', buffered: true });
    } catch (e) {
      console.warn('LCP not supported');
    }

    // Report final LCP when page is hidden
    this.handleVisibilityChange = () => {
      if (document.visibilityState === 'hidden' && this.lastLCP) {
        this.report();
      }
    };

    document.addEventListener('visibilitychange', this.handleVisibilityChange);
  }

  report() {
    if (this.lastLCP) {
      this.callback(this.lastLCP);
      this.lastLCP = null;
    }
  }

  disconnect() {
    this.observer.disconnect();
    document.removeEventListener('visibilitychange', this.handleVisibilityChange);
    this.report();
  }
}

// Usage
const lcpObserver = new LCPObserver((lcp) => {
  console.log('LCP:', lcp.value.toFixed(0) + 'ms');

  // Send to analytics
  sendAnalytics('lcp', {
    value: lcp.value,
    element: lcp.element?.tagName
  });
});

First Input Delay (FID)

class FIDObserver {
  constructor(callback) {
    this.callback = callback;

    this.observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();

      entries.forEach(entry => {
        // FID is the first input delay
        const fid = {
          value: entry.processingStart - entry.startTime,
          name: entry.name,
          target: entry.target,
          startTime: entry.startTime
        };

        this.callback(fid);
      });
    });

    try {
      this.observer.observe({ type: 'first-input', buffered: true });
    } catch (e) {
      console.warn('FID not supported');
    }
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const fidObserver = new FIDObserver((fid) => {
  console.log('FID:', fid.value.toFixed(0) + 'ms');
  console.log('Trigger event:', fid.name);
});

Cumulative Layout Shift (CLS)

class CLSObserver {
  constructor(callback) {
    this.callback = callback;
    this.clsValue = 0;
    this.clsEntries = [];

    // Session window for calculation
    this.sessionValue = 0;
    this.sessionEntries = [];
    this.firstSessionEntry = null;

    this.observer = new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        // Ignore shifts after user input
        if (!entry.hadRecentInput) {
          this.processEntry(entry);
        }
      });
    });

    try {
      this.observer.observe({ type: 'layout-shift', buffered: true });
    } catch (e) {
      console.warn('CLS not supported');
    }

    // Report when page is hidden
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'hidden') {
        this.report();
      }
    });
  }

  processEntry(entry) {
    const firstEntry = this.sessionEntries[0];
    const lastEntry = this.sessionEntries[this.sessionEntries.length - 1];

    // Within 5 seconds and less than 1 second gap from first entry
    if (firstEntry &&
        entry.startTime - lastEntry.startTime < 1000 &&
        entry.startTime - firstEntry.startTime < 5000) {
      this.sessionValue += entry.value;
      this.sessionEntries.push(entry);
    } else {
      // Start new session
      this.sessionValue = entry.value;
      this.sessionEntries = [entry];
    }

    // Update max CLS
    if (this.sessionValue > this.clsValue) {
      this.clsValue = this.sessionValue;
      this.clsEntries = [...this.sessionEntries];
    }
  }

  report() {
    this.callback({
      value: this.clsValue,
      entries: this.clsEntries
    });
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const clsObserver = new CLSObserver((cls) => {
  console.log('CLS:', cls.value.toFixed(4));

  if (cls.value > 0.1) {
    console.warn('CLS exceeds threshold, check layout shift sources');
    cls.entries.forEach(entry => {
      console.log('Shift element:', entry.sources);
    });
  }
});

Resource Monitoring

Resource Loading Performance

class ResourceObserver {
  constructor(options = {}) {
    this.options = {
      onResource: () => {},
      filter: () => true,
      ...options
    };

    this.observer = new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        if (this.options.filter(entry)) {
          const metrics = this.parseResourceTiming(entry);
          this.options.onResource(metrics);
        }
      });
    });

    this.observer.observe({ type: 'resource', buffered: true });
  }

  parseResourceTiming(entry) {
    return {
      name: entry.name,
      initiatorType: entry.initiatorType,

      // Time breakdown
      dns: entry.domainLookupEnd - entry.domainLookupStart,
      tcp: entry.connectEnd - entry.connectStart,
      ssl: entry.secureConnectionStart > 0
        ? entry.connectEnd - entry.secureConnectionStart
        : 0,
      ttfb: entry.responseStart - entry.requestStart,
      download: entry.responseEnd - entry.responseStart,
      total: entry.duration,

      // Transfer info
      transferSize: entry.transferSize,
      encodedBodySize: entry.encodedBodySize,
      decodedBodySize: entry.decodedBodySize,

      // Cache status
      cached: entry.transferSize === 0 && entry.decodedBodySize > 0,

      // Original entry
      entry
    };
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const resourceObserver = new ResourceObserver({
  onResource: (metrics) => {
    // Only care about slow resources
    if (metrics.total > 1000) {
      console.log('Slow resource:', metrics.name);
      console.log('  DNS:', metrics.dns + 'ms');
      console.log('  TCP:', metrics.tcp + 'ms');
      console.log('  TTFB:', metrics.ttfb + 'ms');
      console.log('  Download:', metrics.download + 'ms');
    }
  },
  filter: (entry) => {
    // Filter analytics requests
    return !entry.name.includes('analytics');
  }
});

Resource Waterfall

class ResourceWaterfall {
  constructor(container) {
    this.container = container;
    this.resources = [];

    this.observer = new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        this.resources.push(entry);
      });
      this.render();
    });

    this.observer.observe({ type: 'resource', buffered: true });
  }

  render() {
    const maxTime = Math.max(...this.resources.map(r => r.responseEnd));

    this.container.textContent = '';

    // Create timeline
    const timeline = document.createElement('div');
    timeline.className = 'waterfall-timeline';

    this.resources.forEach(resource => {
      const row = this.createRow(resource, maxTime);
      timeline.appendChild(row);
    });

    this.container.appendChild(timeline);
  }

  createRow(resource, maxTime) {
    const row = document.createElement('div');
    row.className = 'waterfall-row';

    // Resource name
    const name = document.createElement('div');
    name.className = 'resource-name';
    name.textContent = this.getFileName(resource.name);
    name.title = resource.name;

    // Time bar
    const bar = document.createElement('div');
    bar.className = 'resource-bar';

    const startPercent = (resource.startTime / maxTime) * 100;
    const widthPercent = (resource.duration / maxTime) * 100;

    bar.style.marginLeft = startPercent + '%';
    bar.style.width = Math.max(widthPercent, 0.5) + '%';

    // Type color
    bar.classList.add('type-' + resource.initiatorType);

    // Time label
    const time = document.createElement('span');
    time.className = 'resource-time';
    time.textContent = resource.duration.toFixed(0) + 'ms';

    row.appendChild(name);
    row.appendChild(bar);
    row.appendChild(time);

    return row;
  }

  getFileName(url) {
    try {
      return new URL(url).pathname.split('/').pop() || url;
    } catch {
      return url;
    }
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const waterfall = new ResourceWaterfall(
  document.getElementById('waterfall')
);

Long Task Detection

Long Task Monitoring

class LongTaskObserver {
  constructor(options = {}) {
    this.options = {
      threshold: 50,
      onLongTask: () => {},
      ...options
    };

    this.tasks = [];

    this.observer = new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        const task = {
          startTime: entry.startTime,
          duration: entry.duration,
          name: entry.name,
          attribution: entry.attribution
        };

        this.tasks.push(task);
        this.options.onLongTask(task);
      });
    });

    try {
      this.observer.observe({ type: 'longtask', buffered: true });
    } catch (e) {
      console.warn('Long task monitoring not supported');
    }
  }

  getStats() {
    if (this.tasks.length === 0) {
      return { count: 0, total: 0, average: 0, max: 0 };
    }

    const durations = this.tasks.map(t => t.duration);
    const total = durations.reduce((a, b) => a + b, 0);

    return {
      count: this.tasks.length,
      total,
      average: total / this.tasks.length,
      max: Math.max(...durations)
    };
  }

  getTasks() {
    return [...this.tasks];
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const longTaskObserver = new LongTaskObserver({
  onLongTask: (task) => {
    console.warn('Long task detected:', task.duration.toFixed(0) + 'ms');

    if (task.attribution && task.attribution.length > 0) {
      task.attribution.forEach(attr => {
        console.log('  Source:', attr.containerType, attr.containerName);
      });
    }
  }
});

// Get statistics
setTimeout(() => {
  const stats = longTaskObserver.getStats();
  console.log('Long task stats:', stats);
}, 10000);

Frame Rate Monitoring

class FrameRateMonitor {
  constructor(callback) {
    this.callback = callback;
    this.frames = [];
    this.lastTime = performance.now();
    this.running = false;

    // Use PerformanceObserver for frames
    this.observer = new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        if (entry.entryType === 'frame') {
          this.frames.push(entry.duration);
        }
      });
    });

    // frame type may not be supported, use RAF fallback
    this.rafLoop = this.rafLoop.bind(this);
  }

  start() {
    this.running = true;
    this.lastTime = performance.now();
    this.frames = [];
    this.rafLoop();
  }

  rafLoop() {
    if (!this.running) return;

    const now = performance.now();
    const delta = now - this.lastTime;
    this.lastTime = now;

    this.frames.push(delta);

    // Keep last 60 frames
    if (this.frames.length > 60) {
      this.frames.shift();
    }

    // Calculate FPS
    const avgDelta = this.frames.reduce((a, b) => a + b) / this.frames.length;
    const fps = 1000 / avgDelta;

    this.callback({
      fps: Math.round(fps),
      avgFrameTime: avgDelta,
      dropped: this.frames.filter(f => f > 16.67).length
    });

    requestAnimationFrame(this.rafLoop);
  }

  stop() {
    this.running = false;
  }
}

// Usage
const fpsMonitor = new FrameRateMonitor((data) => {
  if (data.fps < 30) {
    console.warn('Low frame rate:', data.fps + ' FPS');
  }
});

fpsMonitor.start();

Custom Marks and Measures

Performance Marking

class PerformanceMarker {
  constructor() {
    this.markers = new Map();
  }

  mark(name) {
    performance.mark(name);
    this.markers.set(name, performance.now());
  }

  measure(name, startMark, endMark) {
    try {
      performance.measure(name, startMark, endMark);
      const entries = performance.getEntriesByName(name, 'measure');
      return entries[entries.length - 1];
    } catch (e) {
      console.error('Measure failed:', e);
      return null;
    }
  }

  measureSince(name, startMark) {
    const endMark = name + '-end';
    this.mark(endMark);
    return this.measure(name, startMark, endMark);
  }

  clear(name) {
    if (name) {
      performance.clearMarks(name);
      performance.clearMeasures(name);
    } else {
      performance.clearMarks();
      performance.clearMeasures();
    }
  }

  getMarks() {
    return performance.getEntriesByType('mark');
  }

  getMeasures() {
    return performance.getEntriesByType('measure');
  }
}

// Usage
const marker = new PerformanceMarker();

// Mark operation start
marker.mark('dataFetch-start');

// Execute operation
await fetchData();

// Measure duration
const measure = marker.measureSince('dataFetch', 'dataFetch-start');
console.log('Data fetch took:', measure.duration + 'ms');

User Action Tracking

class UserActionTracker {
  constructor() {
    this.actions = new Map();

    this.observer = new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        if (entry.entryType === 'measure' && entry.name.startsWith('action:')) {
          const actionName = entry.name.replace('action:', '');
          console.log('Action completed:', actionName, entry.duration + 'ms');
        }
      });
    });

    this.observer.observe({ entryTypes: ['measure'] });
  }

  startAction(name) {
    const markName = 'action-start:' + name;
    performance.mark(markName);
    this.actions.set(name, markName);
  }

  endAction(name, metadata = {}) {
    const startMark = this.actions.get(name);
    if (!startMark) {
      console.warn('Action start mark not found:', name);
      return null;
    }

    const endMark = 'action-end:' + name;
    performance.mark(endMark);

    const measureName = 'action:' + name;
    performance.measure(measureName, startMark, endMark);

    const entries = performance.getEntriesByName(measureName, 'measure');
    const measure = entries[entries.length - 1];

    // Cleanup
    this.actions.delete(name);
    performance.clearMarks(startMark);
    performance.clearMarks(endMark);

    return {
      name,
      duration: measure.duration,
      ...metadata
    };
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const tracker = new UserActionTracker();

// Track form submission
submitButton.addEventListener('click', async () => {
  tracker.startAction('formSubmit');

  try {
    await submitForm();
    const result = tracker.endAction('formSubmit', { success: true });
    console.log('Submit took:', result.duration + 'ms');
  } catch (error) {
    tracker.endAction('formSubmit', { success: false, error: error.message });
  }
});

Performance Reporting

Comprehensive Performance Report

class PerformanceReporter {
  constructor() {
    this.metrics = {};
    this.setupObservers();
  }

  setupObservers() {
    // LCP
    new PerformanceObserver((list) => {
      const entries = list.getEntries();
      this.metrics.lcp = entries[entries.length - 1].startTime;
    }).observe({ type: 'largest-contentful-paint', buffered: true });

    // FID
    new PerformanceObserver((list) => {
      const entry = list.getEntries()[0];
      this.metrics.fid = entry.processingStart - entry.startTime;
    }).observe({ type: 'first-input', buffered: true });

    // CLS
    let clsValue = 0;
    new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
        }
      });
      this.metrics.cls = clsValue;
    }).observe({ type: 'layout-shift', buffered: true });

    // Navigation timing
    new PerformanceObserver((list) => {
      const entry = list.getEntries()[0];
      this.metrics.navigation = {
        dns: entry.domainLookupEnd - entry.domainLookupStart,
        tcp: entry.connectEnd - entry.connectStart,
        ttfb: entry.responseStart - entry.requestStart,
        domLoad: entry.domContentLoadedEventEnd - entry.startTime,
        load: entry.loadEventEnd - entry.startTime
      };
    }).observe({ type: 'navigation', buffered: true });

    // Paint
    new PerformanceObserver((list) => {
      list.getEntries().forEach(entry => {
        if (entry.name === 'first-paint') {
          this.metrics.fp = entry.startTime;
        }
        if (entry.name === 'first-contentful-paint') {
          this.metrics.fcp = entry.startTime;
        }
      });
    }).observe({ type: 'paint', buffered: true });
  }

  getReport() {
    return {
      timestamp: Date.now(),
      url: location.href,
      metrics: { ...this.metrics },
      rating: this.getRating()
    };
  }

  getRating() {
    const { lcp, fid, cls } = this.metrics;

    return {
      lcp: lcp < 2500 ? 'good' : lcp < 4000 ? 'needs-improvement' : 'poor',
      fid: fid < 100 ? 'good' : fid < 300 ? 'needs-improvement' : 'poor',
      cls: cls < 0.1 ? 'good' : cls < 0.25 ? 'needs-improvement' : 'poor'
    };
  }

  send() {
    const report = this.getReport();
    console.log('Performance report:', report);

    // Send to server
    navigator.sendBeacon('/api/performance', JSON.stringify(report));
  }
}

// Usage
const reporter = new PerformanceReporter();

// Send report when page unloads
window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    reporter.send();
  }
});

Best Practices Summary

PerformanceObserver Best Practices:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Monitoring Strategy                               │
│   ├── Use buffered: true for historical data       │
│   ├── Report final data when page is hidden        │
│   ├── Set reasonable sampling rates                │
│   └── Filter irrelevant entries                    │
│                                                     │
│   Core Web Vitals                                   │
│   ├── LCP < 2.5s (good)                            │
│   ├── FID < 100ms (good)                           │
│   ├── CLS < 0.1 (good)                             │
│   └── Use web-vitals library for simplicity        │
│                                                     │
│   Considerations                                    │
│   ├── Check browser support                        │
│   ├── Handle exceptions                            │
│   ├── Avoid monitoring affecting performance       │
│   └── Use sendBeacon for data transmission         │
│                                                     │
└─────────────────────────────────────────────────────┘
MetricGoodNeeds ImprovementPoor
LCP< 2.5s2.5-4s> 4s
FID< 100ms100-300ms> 300ms
CLS< 0.10.1-0.25> 0.25

Master the PerformanceObserver API to build comprehensive performance monitoring systems.