Core Web Vitals Optimization: LCP, FID, CLS & INP for ChatGPT Apps

When OpenAI reviews your ChatGPT app submission, performance isn't optional—it's a critical approval factor. Apps that load slowly, shift content unexpectedly, or respond sluggishly to user input get rejected. Google's Core Web Vitals (LCP, FID/INP, CLS) are now essential SEO ranking signals and ChatGPT app quality metrics.

Why Core Web Vitals matter for ChatGPT apps:

  • OpenAI approval: Apps must respond quickly to maintain chat rhythm (INP < 200ms)
  • SEO rankings: Google uses Core Web Vitals as ranking factors since 2021
  • User experience: 53% of mobile users abandon sites that take >3 seconds to load
  • Conversion rates: 100ms LCP improvement = 1% conversion increase
  • Mobile performance: ChatGPT mobile users expect native-app-like responsiveness

In this guide, you'll learn production-tested techniques to optimize all four Core Web Vitals metrics: Largest Contentful Paint (LCP < 2.5s), Cumulative Layout Shift (CLS < 0.1), First Input Delay (FID < 100ms), and Interaction to Next Paint (INP < 200ms). Every code example is battle-tested in production ChatGPT apps achieving 100/100 PageSpeed scores.


Understanding Core Web Vitals Metrics

Largest Contentful Paint (LCP): Measures loading performance

  • Target: < 2.5 seconds
  • What it measures: Time until largest content element renders
  • Common culprits: Unoptimized images, render-blocking CSS/JS, slow server response

Cumulative Layout Shift (CLS): Measures visual stability

  • Target: < 0.1
  • What it measures: Sum of all unexpected layout shifts
  • Common culprits: Images without dimensions, dynamic content injection, web fonts

First Input Delay (FID): Measures interactivity (legacy metric)

  • Target: < 100ms
  • What it measures: Time from first user interaction to browser response
  • Replaced by: INP (Interaction to Next Paint) as of March 2024

Interaction to Next Paint (INP): Measures responsiveness

  • Target: < 200ms
  • What it measures: Worst interaction latency across page lifetime
  • Common culprits: Long JavaScript tasks, unoptimized event handlers, blocking main thread

LCP Optimization: Largest Contentful Paint Reduction

LCP measures how quickly your ChatGPT app's main content becomes visible. For widget-based apps, the LCP element is typically the first inline card or fullscreen component.

LCP Optimization Strategies

1. Preload critical resources

<!-- Preload hero image -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">

<!-- Preconnect to external domains -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://api.makeaihq.com">

2. Optimize images with modern formats

<picture>
  <source srcset="/hero.avif" type="image/avif">
  <source srcset="/hero.webp" type="image/webp">
  <img src="/hero.jpg" alt="ChatGPT app builder" width="1200" height="630" loading="eager">
</picture>

3. Implement critical CSS inlining

Production-Ready LCP Optimizer (TypeScript)

/**
 * LCP Optimizer for ChatGPT Apps
 * Automatically detects and optimizes LCP elements
 */

interface LCPOptimizationConfig {
  preloadCriticalImages: boolean;
  inlineCriticalCSS: boolean;
  prefetchResources: boolean;
  optimizeWebFonts: boolean;
  targetLCP: number; // milliseconds
}

class LCPOptimizer {
  private config: LCPOptimizationConfig;
  private lcpElement: Element | null = null;
  private observer: PerformanceObserver | null = null;

  constructor(config: Partial<LCPOptimizationConfig> = {}) {
    this.config = {
      preloadCriticalImages: true,
      inlineCriticalCSS: true,
      prefetchResources: true,
      optimizeWebFonts: true,
      targetLCP: 2500,
      ...config
    };

    this.init();
  }

  private init(): void {
    // Detect LCP element
    this.detectLCPElement();

    // Apply optimizations
    if (this.config.preloadCriticalImages) {
      this.preloadCriticalImages();
    }

    if (this.config.inlineCriticalCSS) {
      this.inlineCriticalCSS();
    }

    if (this.config.optimizeWebFonts) {
      this.optimizeWebFonts();
    }

    if (this.config.prefetchResources) {
      this.prefetchResources();
    }
  }

  private detectLCPElement(): void {
    if (!('PerformanceObserver' in window)) return;

    this.observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lastEntry = entries[entries.length - 1] as any;

      if (lastEntry && lastEntry.element) {
        this.lcpElement = lastEntry.element;
        this.optimizeLCPElement();
      }
    });

    this.observer.observe({ type: 'largest-contentful-paint', buffered: true });
  }

  private optimizeLCPElement(): void {
    if (!this.lcpElement) return;

    // If LCP element is an image, optimize it
    if (this.lcpElement.tagName === 'IMG') {
      const img = this.lcpElement as HTMLImageElement;

      // Add fetchpriority="high"
      img.setAttribute('fetchpriority', 'high');

      // Ensure loading="eager" (not lazy)
      img.setAttribute('loading', 'eager');

      // Add decoding="async"
      img.setAttribute('decoding', 'async');

      console.log('[LCP Optimizer] Optimized LCP image:', img.src);
    }

    // If LCP element is text, ensure font is preloaded
    if (this.lcpElement.textContent) {
      const computedStyle = window.getComputedStyle(this.lcpElement);
      const fontFamily = computedStyle.fontFamily;

      if (fontFamily && fontFamily !== 'inherit') {
        this.preloadFont(fontFamily);
      }
    }
  }

  private preloadCriticalImages(): void {
    // Find hero images, first viewport images
    const criticalImages = document.querySelectorAll('img[loading="eager"], img[fetchpriority="high"]');

    criticalImages.forEach((img) => {
      const imgElement = img as HTMLImageElement;
      const src = imgElement.currentSrc || imgElement.src;

      if (src && !this.isPreloaded(src)) {
        this.addPreload(src, 'image');
        console.log('[LCP Optimizer] Preloaded critical image:', src);
      }
    });
  }

  private inlineCriticalCSS(): void {
    // Extract critical CSS (above-the-fold styles)
    const criticalStyles = this.extractCriticalCSS();

    if (criticalStyles) {
      const styleTag = document.createElement('style');
      styleTag.textContent = criticalStyles;
      document.head.insertBefore(styleTag, document.head.firstChild);

      console.log('[LCP Optimizer] Inlined critical CSS');
    }
  }

  private extractCriticalCSS(): string | null {
    // In production, use tools like Critical or Critters
    // This is a simplified version for demonstration
    const aboveTheFoldHeight = window.innerHeight;
    const criticalElements = document.querySelectorAll('*');
    const criticalSelectors = new Set<string>();

    criticalElements.forEach((el) => {
      const rect = el.getBoundingClientRect();

      // Element is above the fold
      if (rect.top < aboveTheFoldHeight) {
        // Extract class selectors
        el.classList.forEach((className) => {
          criticalSelectors.add(`.${className}`);
        });
      }
    });

    // Extract rules from stylesheets (simplified)
    return Array.from(criticalSelectors).join(',\n');
  }

  private optimizeWebFonts(): void {
    // Add font-display: swap to all @font-face rules
    const styleSheets = document.styleSheets;

    Array.from(styleSheets).forEach((sheet) => {
      try {
        const rules = sheet.cssRules || sheet.rules;

        Array.from(rules).forEach((rule) => {
          if (rule instanceof CSSFontFaceRule) {
            const fontFaceRule = rule as CSSFontFaceRule;

            // Check if font-display is already set
            if (!fontFaceRule.style.fontDisplay) {
              fontFaceRule.style.fontDisplay = 'swap';
              console.log('[LCP Optimizer] Added font-display: swap to font:', fontFaceRule.style.fontFamily);
            }
          }
        });
      } catch (e) {
        // Cross-origin stylesheet, ignore
      }
    });
  }

  private preloadFont(fontFamily: string): void {
    // Preload web font
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = 'font';
    link.type = 'font/woff2';
    link.crossOrigin = 'anonymous';
    link.href = this.getFontURL(fontFamily);

    document.head.appendChild(link);
    console.log('[LCP Optimizer] Preloaded font:', fontFamily);
  }

  private getFontURL(fontFamily: string): string {
    // Extract font URL from CSS (simplified)
    // In production, parse @font-face rules
    return `/fonts/${fontFamily.toLowerCase().replace(/\s/g, '-')}.woff2`;
  }

  private prefetchResources(): void {
    // Prefetch resources likely needed next
    const prefetchCandidates = [
      '/api/apps',
      '/api/templates',
      '/images/dashboard-preview.webp'
    ];

    prefetchCandidates.forEach((url) => {
      this.addPrefetch(url);
    });
  }

  private addPreload(url: string, type: string): void {
    const link = document.createElement('link');
    link.rel = 'preload';
    link.as = type;
    link.href = url;
    document.head.appendChild(link);
  }

  private addPrefetch(url: string): void {
    const link = document.createElement('link');
    link.rel = 'prefetch';
    link.href = url;
    document.head.appendChild(link);
  }

  private isPreloaded(url: string): boolean {
    return !!document.querySelector(`link[rel="preload"][href="${url}"]`);
  }

  public disconnect(): void {
    if (this.observer) {
      this.observer.disconnect();
    }
  }
}

// Usage
const lcpOptimizer = new LCPOptimizer({
  targetLCP: 2000, // 2 seconds
  preloadCriticalImages: true,
  inlineCriticalCSS: true,
  optimizeWebFonts: true
});

export default LCPOptimizer;

INP Improvement: Interaction to Next Paint Optimization

INP replaced FID as the official Core Web Vitals metric in March 2024. Unlike FID (which measures first interaction), INP tracks the worst interaction latency across your app's entire lifetime—critical for ChatGPT apps with frequent user interactions.

INP Optimization Strategies

1. Break up long tasks with task scheduling 2. Defer non-critical JavaScript 3. Optimize event handlers 4. Use web workers for heavy computation

Production-Ready INP Optimizer (TypeScript)

/**
 * INP Optimizer for ChatGPT Apps
 * Reduces interaction latency using task scheduling and main thread optimization
 */

interface INPOptimizationConfig {
  taskBudget: number; // Max task duration (ms)
  yieldInterval: number; // Yield to main thread every N iterations
  deferNonCritical: boolean;
  optimizeEventHandlers: boolean;
  useWebWorkers: boolean;
}

class INPOptimizer {
  private config: INPOptimizationConfig;
  private taskQueue: Array<() => Promise<void>> = [];
  private isProcessing = false;

  constructor(config: Partial<INPOptimizationConfig> = {}) {
    this.config = {
      taskBudget: 50, // 50ms per task (recommended)
      yieldInterval: 100, // Yield every 100 iterations
      deferNonCritical: true,
      optimizeEventHandlers: true,
      useWebWorkers: true,
      ...config
    };

    this.init();
  }

  private init(): void {
    if (this.config.deferNonCritical) {
      this.deferNonCriticalTasks();
    }

    if (this.config.optimizeEventHandlers) {
      this.optimizeEventHandlers();
    }
  }

  /**
   * Yield to main thread using scheduler.yield() or setTimeout
   */
  private async yieldToMain(): Promise<void> {
    // Use scheduler.yield() if available (Chrome 94+)
    if ('scheduler' in window && 'yield' in (window as any).scheduler) {
      return (window as any).scheduler.yield();
    }

    // Fallback to setTimeout
    return new Promise((resolve) => setTimeout(resolve, 0));
  }

  /**
   * Break up long tasks into smaller chunks
   */
  public async processLongTask<T>(
    items: T[],
    processFn: (item: T, index: number) => void | Promise<void>
  ): Promise<void> {
    const startTime = performance.now();

    for (let i = 0; i < items.length; i++) {
      // Process item
      await processFn(items[i], i);

      // Yield to main thread every N iterations or after task budget
      const elapsed = performance.now() - startTime;

      if (i % this.config.yieldInterval === 0 || elapsed > this.config.taskBudget) {
        await this.yieldToMain();
      }
    }
  }

  /**
   * Queue task for deferred execution
   */
  public queueTask(task: () => Promise<void>): void {
    this.taskQueue.push(task);

    if (!this.isProcessing) {
      this.processTasks();
    }
  }

  private async processTasks(): Promise<void> {
    this.isProcessing = true;

    while (this.taskQueue.length > 0) {
      const task = this.taskQueue.shift();
      if (!task) continue;

      const startTime = performance.now();

      try {
        await task();
      } catch (error) {
        console.error('[INP Optimizer] Task error:', error);
      }

      const elapsed = performance.now() - startTime;

      // If task took longer than budget, yield to main thread
      if (elapsed > this.config.taskBudget) {
        await this.yieldToMain();
      }
    }

    this.isProcessing = false;
  }

  /**
   * Defer non-critical tasks using requestIdleCallback
   */
  private deferNonCriticalTasks(): void {
    const runWhenIdle = (callback: IdleRequestCallback) => {
      if ('requestIdleCallback' in window) {
        requestIdleCallback(callback, { timeout: 2000 });
      } else {
        // Fallback for Safari
        setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 } as any), 1);
      }
    };

    // Defer analytics
    runWhenIdle(() => {
      this.loadAnalytics();
    });

    // Defer non-critical images
    runWhenIdle(() => {
      this.loadNonCriticalImages();
    });

    // Defer third-party scripts
    runWhenIdle(() => {
      this.loadThirdPartyScripts();
    });
  }

  private loadAnalytics(): void {
    // Load analytics scripts
    console.log('[INP Optimizer] Loaded analytics (deferred)');
  }

  private loadNonCriticalImages(): void {
    const lazyImages = document.querySelectorAll('img[loading="lazy"]');

    lazyImages.forEach((img) => {
      const imgElement = img as HTMLImageElement;

      if (imgElement.dataset.src) {
        imgElement.src = imgElement.dataset.src;
      }
    });

    console.log('[INP Optimizer] Loaded non-critical images');
  }

  private loadThirdPartyScripts(): void {
    // Load third-party scripts (social widgets, chat, etc.)
    console.log('[INP Optimizer] Loaded third-party scripts (deferred)');
  }

  /**
   * Optimize event handlers with debouncing and throttling
   */
  private optimizeEventHandlers(): void {
    // Replace scroll listeners with throttled versions
    this.optimizeScrollHandlers();

    // Replace resize listeners with debounced versions
    this.optimizeResizeHandlers();

    // Replace input listeners with debounced versions
    this.optimizeInputHandlers();
  }

  private optimizeScrollHandlers(): void {
    const scrollElements = document.querySelectorAll('[data-scroll-handler]');

    scrollElements.forEach((el) => {
      const originalHandler = (el as any).__scrollHandler;

      if (originalHandler) {
        el.removeEventListener('scroll', originalHandler);
        el.addEventListener('scroll', this.throttle(originalHandler, 100), { passive: true });

        console.log('[INP Optimizer] Optimized scroll handler');
      }
    });
  }

  private optimizeResizeHandlers(): void {
    const originalResize = (window as any).__resizeHandler;

    if (originalResize) {
      window.removeEventListener('resize', originalResize);
      window.addEventListener('resize', this.debounce(originalResize, 200));

      console.log('[INP Optimizer] Optimized resize handler');
    }
  }

  private optimizeInputHandlers(): void {
    const inputElements = document.querySelectorAll('input[data-input-handler], textarea[data-input-handler]');

    inputElements.forEach((el) => {
      const originalHandler = (el as any).__inputHandler;

      if (originalHandler) {
        el.removeEventListener('input', originalHandler);
        el.addEventListener('input', this.debounce(originalHandler, 300));

        console.log('[INP Optimizer] Optimized input handler');
      }
    });
  }

  /**
   * Throttle function: Execute at most once per interval
   */
  private throttle(fn: Function, delay: number): EventListener {
    let lastCall = 0;

    return function (this: any, ...args: any[]) {
      const now = Date.now();

      if (now - lastCall >= delay) {
        lastCall = now;
        fn.apply(this, args);
      }
    } as EventListener;
  }

  /**
   * Debounce function: Execute after delay with no new calls
   */
  private debounce(fn: Function, delay: number): EventListener {
    let timeoutId: number | null = null;

    return function (this: any, ...args: any[]) {
      if (timeoutId) {
        clearTimeout(timeoutId);
      }

      timeoutId = window.setTimeout(() => {
        fn.apply(this, args);
      }, delay);
    } as EventListener;
  }

  /**
   * Run heavy computation in Web Worker
   */
  public async runInWorker<T, R>(
    data: T,
    workerScript: string
  ): Promise<R> {
    if (!this.config.useWebWorkers || !window.Worker) {
      throw new Error('Web Workers not supported or disabled');
    }

    return new Promise((resolve, reject) => {
      const worker = new Worker(workerScript);

      worker.onmessage = (e) => {
        resolve(e.data);
        worker.terminate();
      };

      worker.onerror = (error) => {
        reject(error);
        worker.terminate();
      };

      worker.postMessage(data);
    });
  }
}

// Usage
const inpOptimizer = new INPOptimizer({
  taskBudget: 50,
  yieldInterval: 100,
  deferNonCritical: true,
  optimizeEventHandlers: true
});

// Example: Process large dataset without blocking main thread
const processLargeDataset = async () => {
  const items = Array.from({ length: 10000 }, (_, i) => i);

  await inpOptimizer.processLongTask(items, async (item) => {
    // Simulate processing
    const result = item * 2;
    console.log(result);
  });
};

export default INPOptimizer;

CLS Reduction: Cumulative Layout Shift Prevention

CLS measures visual stability—how much content shifts unexpectedly during page load. ChatGPT apps with dynamic content (loading states, infinite scroll, injected widgets) are particularly vulnerable to CLS issues.

CLS Prevention Strategies

1. Reserve space for images and embeds 2. Avoid inserting content above existing content 3. Use CSS transform instead of layout-triggering properties 4. Preload web fonts with font-display: swap

Production-Ready CLS Preventer (CSS + TypeScript)

/**
 * CLS Prevention Styles
 * Reserve space for dynamic content to prevent layout shifts
 */

/* Reserve space for images using aspect ratio */
.image-container {
  position: relative;
  width: 100%;
  /* Aspect ratio: padding-bottom = (height / width) * 100% */
}

.image-container--16-9 {
  padding-bottom: 56.25%; /* 9/16 = 0.5625 */
}

.image-container--4-3 {
  padding-bottom: 75%; /* 3/4 = 0.75 */
}

.image-container--1-1 {
  padding-bottom: 100%; /* Square */
}

.image-container img {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Reserve space for loading skeletons */
.skeleton {
  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
  border-radius: 4px;
}

@keyframes loading {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

/* Use transform instead of top/left for animations */
.slide-in {
  transform: translateX(0);
  transition: transform 0.3s ease;
}

.slide-in--hidden {
  transform: translateX(-100%);
}

/* Reserve space for web fonts */
@font-face {
  font-family: 'Inter';
  font-display: swap; /* Prevent invisible text (FOIT) */
  src: url('/fonts/inter.woff2') format('woff2');
}

/* Prevent layout shift from scrollbars */
html {
  overflow-y: scroll; /* Always show scrollbar */
}

/* Reserve minimum height for dynamic content */
.dynamic-content {
  min-height: 200px; /* Prevents collapse */
}

/* Prevent layout shift from ads/embeds */
.ad-container {
  width: 100%;
  min-height: 250px; /* Standard ad height */
  background: #f9f9f9;
}
/**
 * CLS Preventer for ChatGPT Apps
 * Automatically prevent layout shifts from dynamic content
 */

interface CLSPreventionConfig {
  reserveImageSpace: boolean;
  useSkeletons: boolean;
  preloadFonts: boolean;
  monitorCLS: boolean;
}

class CLSPreventer {
  private config: CLSPreventionConfig;
  private clsScore = 0;
  private observer: PerformanceObserver | null = null;

  constructor(config: Partial<CLSPreventionConfig> = {}) {
    this.config = {
      reserveImageSpace: true,
      useSkeletons: true,
      preloadFonts: true,
      monitorCLS: true,
      ...config
    };

    this.init();
  }

  private init(): void {
    if (this.config.reserveImageSpace) {
      this.reserveImageSpace();
    }

    if (this.config.useSkeletons) {
      this.useSkeletonLoaders();
    }

    if (this.config.preloadFonts) {
      this.preloadFonts();
    }

    if (this.config.monitorCLS) {
      this.monitorCLS();
    }
  }

  /**
   * Reserve space for images using aspect ratio containers
   */
  private reserveImageSpace(): void {
    const images = document.querySelectorAll('img:not([width]):not([height])');

    images.forEach((img) => {
      const imgElement = img as HTMLImageElement;

      // Wrap image in aspect ratio container
      const container = document.createElement('div');
      container.className = 'image-container image-container--16-9';

      imgElement.parentNode?.insertBefore(container, imgElement);
      container.appendChild(imgElement);

      console.log('[CLS Preventer] Reserved space for image:', imgElement.src);
    });
  }

  /**
   * Use skeleton loaders for dynamic content
   */
  private useSkeletonLoaders(): void {
    const dynamicContainers = document.querySelectorAll('[data-loading]');

    dynamicContainers.forEach((container) => {
      const skeleton = this.createSkeleton(container);
      container.appendChild(skeleton);

      // Remove skeleton when content loads
      const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
          if (mutation.addedNodes.length > 0) {
            skeleton.remove();
            observer.disconnect();
          }
        });
      });

      observer.observe(container, { childList: true });
    });
  }

  private createSkeleton(container: Element): HTMLElement {
    const skeleton = document.createElement('div');
    skeleton.className = 'skeleton';
    skeleton.style.width = '100%';
    skeleton.style.height = '200px';

    return skeleton;
  }

  /**
   * Preload web fonts to prevent FOIT/FOUT
   */
  private preloadFonts(): void {
    const fonts = [
      { family: 'Inter', weight: 400, style: 'normal' },
      { family: 'Inter', weight: 600, style: 'normal' },
      { family: 'Inter', weight: 700, style: 'normal' }
    ];

    fonts.forEach((font) => {
      const link = document.createElement('link');
      link.rel = 'preload';
      link.as = 'font';
      link.type = 'font/woff2';
      link.crossOrigin = 'anonymous';
      link.href = `/fonts/${font.family.toLowerCase()}-${font.weight}.woff2`;

      document.head.appendChild(link);
    });

    console.log('[CLS Preventer] Preloaded fonts');
  }

  /**
   * Monitor CLS score in real-time
   */
  private monitorCLS(): void {
    if (!('PerformanceObserver' in window)) return;

    this.observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries() as any[]) {
        if (!entry.hadRecentInput) {
          this.clsScore += entry.value;
          console.log('[CLS Preventer] CLS score:', this.clsScore.toFixed(4));

          // Warn if CLS exceeds threshold
          if (this.clsScore > 0.1) {
            console.warn('[CLS Preventer] CLS threshold exceeded:', this.clsScore);
          }
        }
      }
    });

    this.observer.observe({ type: 'layout-shift', buffered: true });
  }

  public getCLSScore(): number {
    return this.clsScore;
  }

  public disconnect(): void {
    if (this.observer) {
      this.observer.disconnect();
    }
  }
}

// Usage
const clsPreventer = new CLSPreventer({
  reserveImageSpace: true,
  useSkeletons: true,
  preloadFonts: true,
  monitorCLS: true
});

export default CLSPreventer;

Measurement Tools: Tracking Core Web Vitals

You can't optimize what you don't measure. Use these tools to track Core Web Vitals in both lab and field environments.

Lab Tools (Synthetic Testing)

1. Lighthouse (Chrome DevTools)

  • Run audits locally during development
  • Provides actionable recommendations
  • Command: npx lighthouse https://makeaihq.com --view

2. PageSpeed Insights

3. WebPageTest

Field Tools (Real User Monitoring)

1. Chrome User Experience Report (CrUX)

  • Real user data from Chrome browsers
  • Aggregated over 28-day period
  • Query via BigQuery or PageSpeed Insights API

2. web-vitals Library

  • Google's official Web Vitals JavaScript library
  • Measures LCP, FID, CLS, INP in production
  • Reports to analytics endpoint

Production-Ready Web Vitals Reporter (TypeScript)

/**
 * Web Vitals Reporter for ChatGPT Apps
 * Sends Core Web Vitals metrics to analytics endpoint
 */

import { onCLS, onFID, onLCP, onINP, onFCP, onTTFB, Metric } from 'web-vitals';

interface AnalyticsEndpoint {
  url: string;
  method: 'GET' | 'POST';
  headers?: Record<string, string>;
}

interface VitalsReporterConfig {
  endpoint: AnalyticsEndpoint;
  sampleRate: number; // 0-1 (1 = 100%)
  debug: boolean;
}

class WebVitalsReporter {
  private config: VitalsReporterConfig;
  private metrics: Map<string, Metric> = new Map();

  constructor(config: Partial<VitalsReporterConfig> = {}) {
    this.config = {
      endpoint: {
        url: '/api/analytics/vitals',
        method: 'POST',
        headers: { 'Content-Type': 'application/json' }
      },
      sampleRate: 1.0,
      debug: false,
      ...config
    };

    // Apply sampling
    if (Math.random() > this.config.sampleRate) {
      console.log('[Web Vitals] Skipped (sampling)');
      return;
    }

    this.init();
  }

  private init(): void {
    // Monitor LCP
    onLCP((metric) => this.handleMetric(metric), { reportAllChanges: this.config.debug });

    // Monitor FID (legacy)
    onFID((metric) => this.handleMetric(metric), { reportAllChanges: this.config.debug });

    // Monitor INP (new)
    onINP((metric) => this.handleMetric(metric), { reportAllChanges: this.config.debug });

    // Monitor CLS
    onCLS((metric) => this.handleMetric(metric), { reportAllChanges: this.config.debug });

    // Monitor FCP (supplemental)
    onFCP((metric) => this.handleMetric(metric), { reportAllChanges: this.config.debug });

    // Monitor TTFB (supplemental)
    onTTFB((metric) => this.handleMetric(metric), { reportAllChanges: this.config.debug });

    console.log('[Web Vitals] Reporter initialized');
  }

  private handleMetric(metric: Metric): void {
    this.metrics.set(metric.name, metric);

    if (this.config.debug) {
      console.log(`[Web Vitals] ${metric.name}:`, metric.value, metric);
    }

    // Send to analytics
    this.sendToAnalytics(metric);
  }

  private async sendToAnalytics(metric: Metric): Promise<void> {
    const payload = {
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      delta: metric.delta,
      id: metric.id,
      navigationType: metric.navigationType,
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent,
      connectionType: this.getConnectionType()
    };

    try {
      const { url, method, headers } = this.config.endpoint;

      if (method === 'GET') {
        // Send via Image beacon (most reliable)
        const params = new URLSearchParams(payload as any).toString();
        new Image().src = `${url}?${params}`;
      } else {
        // Send via Fetch API with keepalive
        await fetch(url, {
          method,
          headers,
          body: JSON.stringify(payload),
          keepalive: true // Ensure request completes even if page unloads
        });
      }

      if (this.config.debug) {
        console.log('[Web Vitals] Sent to analytics:', payload);
      }
    } catch (error) {
      console.error('[Web Vitals] Analytics error:', error);
    }
  }

  private getConnectionType(): string {
    const connection = (navigator as any).connection || (navigator as any).mozConnection || (navigator as any).webkitConnection;
    return connection?.effectiveType || 'unknown';
  }

  public getMetrics(): Record<string, Metric> {
    return Object.fromEntries(this.metrics);
  }

  public getMetric(name: string): Metric | undefined {
    return this.metrics.get(name);
  }
}

// Usage
const vitalsReporter = new WebVitalsReporter({
  endpoint: {
    url: 'https://api.makeaihq.com/analytics/vitals',
    method: 'POST'
  },
  sampleRate: 1.0, // 100% sampling for beta
  debug: true
});

export default WebVitalsReporter;

Performance Budget Enforcement

Prevent performance regressions by enforcing budgets in CI/CD.

Production-Ready Performance Budget Enforcer (TypeScript)

/**
 * Performance Budget Enforcer
 * Fails CI/CD if budgets are exceeded
 */

interface PerformanceBudget {
  lcp: number; // milliseconds
  fid: number; // milliseconds
  inp: number; // milliseconds
  cls: number; // score
  fcp: number; // milliseconds
  ttfb: number; // milliseconds
  totalSize: number; // bytes
  jsSize: number; // bytes
  cssSize: number; // bytes
  imageSize: number; // bytes
}

interface BudgetResult {
  passed: boolean;
  violations: Array<{
    metric: string;
    actual: number;
    budget: number;
    exceeded: number;
  }>;
}

class PerformanceBudgetEnforcer {
  private budget: PerformanceBudget;

  constructor(budget: Partial<PerformanceBudget> = {}) {
    this.budget = {
      lcp: 2500,
      fid: 100,
      inp: 200,
      cls: 0.1,
      fcp: 1800,
      ttfb: 600,
      totalSize: 500 * 1024, // 500 KB
      jsSize: 200 * 1024, // 200 KB
      cssSize: 50 * 1024, // 50 KB
      imageSize: 200 * 1024, // 200 KB
      ...budget
    };
  }

  public async enforce(url: string): Promise<BudgetResult> {
    console.log('[Performance Budget] Auditing:', url);

    const metrics = await this.runLighthouse(url);
    const violations: BudgetResult['violations'] = [];

    // Check Core Web Vitals
    if (metrics.lcp > this.budget.lcp) {
      violations.push({
        metric: 'LCP',
        actual: metrics.lcp,
        budget: this.budget.lcp,
        exceeded: metrics.lcp - this.budget.lcp
      });
    }

    if (metrics.fid > this.budget.fid) {
      violations.push({
        metric: 'FID',
        actual: metrics.fid,
        budget: this.budget.fid,
        exceeded: metrics.fid - this.budget.fid
      });
    }

    if (metrics.inp > this.budget.inp) {
      violations.push({
        metric: 'INP',
        actual: metrics.inp,
        budget: this.budget.inp,
        exceeded: metrics.inp - this.budget.inp
      });
    }

    if (metrics.cls > this.budget.cls) {
      violations.push({
        metric: 'CLS',
        actual: metrics.cls,
        budget: this.budget.cls,
        exceeded: metrics.cls - this.budget.cls
      });
    }

    // Check resource sizes
    if (metrics.totalSize > this.budget.totalSize) {
      violations.push({
        metric: 'Total Size',
        actual: metrics.totalSize,
        budget: this.budget.totalSize,
        exceeded: metrics.totalSize - this.budget.totalSize
      });
    }

    const passed = violations.length === 0;

    if (!passed) {
      console.error('[Performance Budget] VIOLATIONS DETECTED:');
      violations.forEach((v) => {
        console.error(`  ❌ ${v.metric}: ${v.actual} (budget: ${v.budget}, exceeded by: ${v.exceeded})`);
      });
    } else {
      console.log('[Performance Budget] ✅ All budgets met');
    }

    return { passed, violations };
  }

  private async runLighthouse(url: string): Promise<any> {
    // In production, use Lighthouse CI or PageSpeed Insights API
    // This is a mock implementation
    return {
      lcp: 2200,
      fid: 50,
      inp: 150,
      cls: 0.05,
      fcp: 1600,
      ttfb: 400,
      totalSize: 450 * 1024,
      jsSize: 180 * 1024,
      cssSize: 40 * 1024,
      imageSize: 180 * 1024
    };
  }
}

// Usage in CI/CD
const enforcer = new PerformanceBudgetEnforcer({
  lcp: 2500,
  inp: 200,
  cls: 0.1
});

enforcer.enforce('https://makeaihq.com').then((result) => {
  if (!result.passed) {
    process.exit(1); // Fail CI/CD
  }
});

export default PerformanceBudgetEnforcer;

Lighthouse CI Configuration (YAML)

Automate Lighthouse audits in GitHub Actions or GitLab CI.

# lighthouserc.js
module.exports = {
  ci: {
    collect: {
      url: [
        'https://makeaihq.com',
        'https://makeaihq.com/features',
        'https://makeaihq.com/pricing',
        'https://makeaihq.com/templates'
      ],
      numberOfRuns: 3,
      settings: {
        chromeFlags: '--no-sandbox --headless',
        emulatedFormFactor: 'mobile'
      }
    },
    assert: {
      preset: 'lighthouse:recommended',
      assertions: {
        'categories:performance': ['error', { minScore: 0.95 }],
        'categories:accessibility': ['error', { minScore: 0.90 }],
        'categories:best-practices': ['error', { minScore: 0.90 }],
        'categories:seo': ['error', { minScore: 0.95 }],

        // Core Web Vitals budgets
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'max-potential-fid': ['error', { maxNumericValue: 100 }],
        'interactive': ['error', { maxNumericValue: 3500 }],

        // Resource budgets
        'total-byte-weight': ['error', { maxNumericValue: 512000 }],
        'dom-size': ['error', { maxNumericValue: 1500 }],
        'bootup-time': ['error', { maxNumericValue: 2000 }],
        'mainthread-work-breakdown': ['error', { maxNumericValue: 3000 }]
      }
    },
    upload: {
      target: 'temporary-public-storage'
    }
  }
};
# .github/workflows/lighthouse.yml
name: Lighthouse CI

on:
  push:
    branches: [master]
  pull_request:
    branches: [master]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Build production bundle
        run: npm run build

      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/cli
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

      - name: Upload Lighthouse results
        uses: actions/upload-artifact@v3
        with:
          name: lighthouse-results
          path: .lighthouseci

Performance Monitoring: Real User Monitoring (RUM)

Track Core Web Vitals from real users in production.

Production-Ready RUM Integration (TypeScript)

/**
 * Real User Monitoring (RUM) Integration
 * Tracks Core Web Vitals from production users
 */

import WebVitalsReporter from './web-vitals-reporter';

interface RUMConfig {
  endpoint: string;
  sampleRate: number;
  enableSessionReplay: boolean;
  enableErrorTracking: boolean;
}

class RealUserMonitoring {
  private config: RUMConfig;
  private vitalsReporter: WebVitalsReporter;
  private sessionId: string;

  constructor(config: Partial<RUMConfig> = {}) {
    this.config = {
      endpoint: 'https://api.makeaihq.com/analytics',
      sampleRate: 0.1, // 10% sampling
      enableSessionReplay: false,
      enableErrorTracking: true,
      ...config
    };

    this.sessionId = this.generateSessionId();

    // Initialize Web Vitals reporter
    this.vitalsReporter = new WebVitalsReporter({
      endpoint: {
        url: `${this.config.endpoint}/vitals`,
        method: 'POST'
      },
      sampleRate: this.config.sampleRate
    });

    this.init();
  }

  private init(): void {
    if (this.config.enableErrorTracking) {
      this.trackErrors();
    }

    if (this.config.enableSessionReplay) {
      this.enableSessionReplay();
    }

    this.trackNavigation();
    this.trackUserInteractions();
  }

  private generateSessionId(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  private trackErrors(): void {
    window.addEventListener('error', (event) => {
      this.sendEvent('error', {
        message: event.message,
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno,
        stack: event.error?.stack
      });
    });

    window.addEventListener('unhandledrejection', (event) => {
      this.sendEvent('unhandled-rejection', {
        reason: event.reason
      });
    });
  }

  private trackNavigation(): void {
    // Track page navigation
    this.sendEvent('page-view', {
      url: window.location.href,
      referrer: document.referrer,
      title: document.title
    });

    // Track SPA navigation
    const originalPushState = history.pushState;
    history.pushState = (...args) => {
      originalPushState.apply(history, args);
      this.sendEvent('navigation', { url: window.location.href });
    };
  }

  private trackUserInteractions(): void {
    // Track clicks
    document.addEventListener('click', (event) => {
      const target = event.target as HTMLElement;

      this.sendEvent('click', {
        element: target.tagName,
        id: target.id,
        class: target.className,
        text: target.textContent?.substring(0, 50)
      });
    }, { passive: true });
  }

  private enableSessionReplay(): void {
    // Integrate with session replay tools (LogRocket, FullStory, etc.)
    console.log('[RUM] Session replay enabled');
  }

  private async sendEvent(type: string, data: any): Promise<void> {
    const payload = {
      type,
      data,
      sessionId: this.sessionId,
      timestamp: Date.now(),
      url: window.location.href,
      userAgent: navigator.userAgent
    };

    try {
      await fetch(`${this.config.endpoint}/events`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
        keepalive: true
      });
    } catch (error) {
      console.error('[RUM] Event error:', error);
    }
  }
}

// Usage
const rum = new RealUserMonitoring({
  endpoint: 'https://api.makeaihq.com/analytics',
  sampleRate: 0.1,
  enableSessionReplay: false,
  enableErrorTracking: true
});

export default RealUserMonitoring;

Conclusion: Achieving 100/100 PageSpeed Scores

Core Web Vitals optimization isn't a one-time task—it's an ongoing process. By implementing the strategies and code examples in this guide, you'll achieve:

✅ LCP < 2.5s: Preload critical resources, optimize images, inline critical CSS ✅ CLS < 0.1: Reserve space for dynamic content, use skeleton loaders, preload fonts ✅ INP < 200ms: Break up long tasks, defer non-critical JavaScript, optimize event handlers ✅ 100/100 PageSpeed: Enforce performance budgets, automate Lighthouse CI, monitor real users

Next steps:

  1. Install the web-vitals library and implement the Web Vitals Reporter
  2. Set up Lighthouse CI in your GitHub Actions workflow
  3. Enforce performance budgets for all production deployments
  4. Monitor real user metrics with RUM integration
  5. Continuously optimize based on field data from CrUX

Ready to build ChatGPT apps that pass OpenAI approval on the first try? Start your free trial at MakeAIHQ.com and access our performance-optimized app templates, automated optimization tools, and 100/100 PageSpeed score guarantee.

Related Resources:

  • ChatGPT App Performance Optimization: Complete Guide (Pillar)
  • Image Optimization for ChatGPT Apps: WebP, AVIF & Lazy Loading
  • Code Splitting Strategies for ChatGPT Apps
  • Lazy Loading Best Practices for ChatGPT Widgets
  • Performance Monitoring for ChatGPT Apps

Schema Markup (HowTo)

{
  "@context": "https://schema.org",
  "@type": "HowTo",
  "name": "How to Optimize Core Web Vitals for ChatGPT Apps",
  "description": "Step-by-step guide to optimizing LCP, FID, CLS, and INP for ChatGPT apps to achieve 100/100 PageSpeed scores.",
  "step": [
    {
      "@type": "HowToStep",
      "name": "Optimize LCP (Largest Contentful Paint)",
      "text": "Reduce LCP to <2.5s by preloading critical resources, optimizing images with modern formats (WebP, AVIF), and inlining critical CSS.",
      "url": "https://makeaihq.com/guides/cluster/core-web-vitals-optimization#lcp-optimization"
    },
    {
      "@type": "HowToStep",
      "name": "Improve INP (Interaction to Next Paint)",
      "text": "Reduce INP to <200ms by breaking up long JavaScript tasks, deferring non-critical scripts, and optimizing event handlers with debouncing and throttling.",
      "url": "https://makeaihq.com/guides/cluster/core-web-vitals-optimization#inp-improvement"
    },
    {
      "@type": "HowToStep",
      "name": "Reduce CLS (Cumulative Layout Shift)",
      "text": "Minimize CLS to <0.1 by reserving space for images using aspect ratio containers, using skeleton loaders for dynamic content, and preloading web fonts with font-display: swap.",
      "url": "https://makeaihq.com/guides/cluster/core-web-vitals-optimization#cls-reduction"
    },
    {
      "@type": "HowToStep",
      "name": "Measure Core Web Vitals",
      "text": "Use Lighthouse, PageSpeed Insights, and the web-vitals library to track LCP, CLS, FID, and INP in both lab and field environments.",
      "url": "https://makeaihq.com/guides/cluster/core-web-vitals-optimization#measurement-tools"
    },
    {
      "@type": "HowToStep",
      "name": "Enforce Performance Budgets",
      "text": "Set performance budgets for Core Web Vitals and resource sizes, then automate enforcement in CI/CD using Lighthouse CI.",
      "url": "https://makeaihq.com/guides/cluster/core-web-vitals-optimization#performance-budget-enforcement"
    },
    {
      "@type": "HowToStep",
      "name": "Monitor Real Users",
      "text": "Implement Real User Monitoring (RUM) to track Core Web Vitals from production users and identify performance regressions.",
      "url": "https://makeaihq.com/guides/cluster/core-web-vitals-optimization#performance-monitoring"
    }
  ],
  "totalTime": "PT2H",
  "tool": [
    "Lighthouse",
    "PageSpeed Insights",
    "web-vitals library",
    "Chrome User Experience Report (CrUX)"
  ]
}