JavaScript PerformanceObserver API Complete Guide

Master performance monitoring: Core Web Vitals, resource loading, long task detection, and performance analysis

JavaScript PerformanceObserver API Complete Guide

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.