Sports Data Infrastructure

    Odds Widgets for Publishers: Embedding Without Performance Impact

    Guide to embedding odds widgets on publisher sites without degrading page performance.

    13 min read3,015 words
    Share
    TL;DR

    What happened? The odds widget provider built for functionality, not for performance. They didn't think about 50M monthly visitors. They thought about accuracy and features.

    The Widget Performance Problem

    Your site gets 50M monthly visitors. You embed an odds widget from a data provider. Suddenly:

    1. Page load time increases from 2.5 seconds to 4.2 seconds
    2. Largest Contentful Paint (LCP) grows from 1.8s to 3.1s
    3. Cumulative Layout Shift (CLS) jumps because the widget loads and causes content to shift
    4. Bounce rate increases by 12%
    5. SEO ranking drops (Google penalizes slow pages)
    6. User complaints: "Your site is slower than competitors"

    What happened? The odds widget provider built for functionality, not for performance. They didn't think about 50M monthly visitors. They thought about accuracy and features.

    This guide walks through how to embed odds widgets without destroying your site's performance.

    Understanding Widget Performance Impact

    A widget affects three main performance metrics:

    1. Time to Interactive (TTI)

    TTI = time until the page is fully interactive (JavaScript loaded and executed).

    Without widget: 2.5 seconds With basic widget: 4.2 seconds (1.7 second penalty) With optimised widget: 2.8 seconds (0.3 second penalty)

    The penalty comes from:

    • Widget JavaScript bundle size
    • Widget parsing and compilation time
    • Widget DOM operations (rendering)
    • Widget startup time (initializing connections)

    2. Cumulative Layout Shift (CLS)

    CLS = how much the page content moves around during loading.

    Without widget: 0.08 (good) With basic widget: 0.24 (poor) With optimised widget: 0.10 (good)

    CLS happens because:

    • Widget reserves space but doesn't fill it immediately
    • Widget loads, pushing content down
    • Widget dimension changes as odds update
    • Widget ads or banners load on top

    3. First Input Delay (FID) / Interaction to Next Paint (INP)

    FID = how fast the page responds to user interaction (click, scroll, type).

    Without widget: 50ms (good) With basic widget: 180ms (poor) With optimised widget: 65ms (good)

    FID degrades because:

    • Widget JavaScript runs on the main thread
    • Widget updates cause re-renders
    • Widget event handlers block user interactions
    • Widget network requests compete for bandwidth

    The Architecture for Non-Blocking Widgets

    The key principle: the widget should never block the page's main thread or critical resources.

    Pattern 1: Lazy Loading (Intersection Observer)

    Don't load the widget until the user actually scrolls to it:

    <div id="odds-widget-container" data-lazy="true"></div>
    
    <script>
    // Only load widget when visible
    const container = document.getElementById('odds-widget-container');
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          loadOddsWidget(container);
          observer.unobserve(container); // Load once, then stop observing
        }
      });
    });
    
    observer.observe(container);
    
    function loadOddsWidget(container) {
      // Load widget script
      const script = document.createElement('script');
      script.src = 'https://odds.fairplay.com/widget.js';
      script.async = true;
      document.body.appendChild(script);
    
      // When widget is ready, initialize
      window.FairPlayWidgetReady = () => {
        FairPlayWidget.render(container, {
          eventId: 'soccer_mancity_liverpool',
          theme: 'light'
        });
      };
    }
    </script>
    

    Impact:

    • Page loads completely before widget
    • Widget loads in background
    • User never notices delay
    • TTI penalty: almost zero

    Pattern 2: Async Script Loading

    Load the widget script asynchronously, not blocking page rendering:

    <!-- ❌ WRONG: This blocks the page -->
    <script src="https://odds.fairplay.com/widget.js"></script>
    
    <!-- ✅ RIGHT: This doesn't block -->
    <script src="https://odds.fairplay.com/widget.js" async></script>
    
    <!-- ✅ ALSO RIGHT: Explicit async with onload handler -->
    <script>
      const script = document.createElement('script');
      script.src = 'https://odds.fairplay.com/widget.js';
      script.async = true;
      script.onload = () => {
        // Widget is loaded, safe to use
        initializeWidget();
      };
      document.body.appendChild(script);
    </script>
    

    Pattern 3: Web Worker (Advanced)

    For heavy computation (calculating best odds across 50 bookmakers), offload to a Web Worker:

    // main.js
    const worker = new Worker('odds-calculator.worker.js');
    
    function calculateBestOdds(oddsData) {
      worker.postMessage({
        type: 'calculate_best_odds',
        data: oddsData
      });
    
      worker.onmessage = (event) => {
        const bestOdds = event.data;
        renderWidget(bestOdds);
      };
    }
    
    // odds-calculator.worker.js (runs on separate thread)
    self.onmessage = (event) => {
      if (event.data.type === 'calculate_best_odds') {
        const bestOdds = calculateBestOdds(event.data.data);
        self.postMessage(bestOdds);
      }
    };
    
    function calculateBestOdds(oddsData) {
      // Heavy computation here
      // Doesn't block main thread
    }
    

    Impact:

    • Computation doesn't freeze the page
    • Users can scroll, click, interact while widget calculates
    • Main thread stays responsive

    Pattern 4: Caching Strategy

    Cache odds data so widgets don't hammer the API:

    class OddsCache {
      constructor(ttl = 5000) {
        this.cache = new Map();
        this.ttl = ttl;
      }
    
      get(key) {
        const item = this.cache.get(key);
        if (!item) return null;
    
        if (Date.now() - item.timestamp > this.ttl) {
          this.cache.delete(key);
          return null;
        }
    
        return item.value;
      }
    
      set(key, value) {
        this.cache.set(key, {
          value,
          timestamp: Date.now()
        });
      }
    
      clear() {
        this.cache.clear();
      }
    }
    
    // Usage
    const cache = new OddsCache(5000); // 5 second TTL
    
    async function getOdds(eventId) {
      // Check cache first
      const cached = cache.get(eventId);
      if (cached) return cached;
    
      // Fetch from API if not cached
      const response = await fetch(`/api/odds/${eventId}`);
      const odds = await response.json();
    
      // Store in cache
      cache.set(eventId, odds);
    
      return odds;
    }
    

    Impact:

    • Reduce API calls by 80-90%
    • Lower bandwidth
    • Faster widget updates (local cache vs network)

    Optimising Bundle Size

    Widget JavaScript bundle size directly impacts load time.

    Measure Current Size

    # Check widget bundle size
    curl -I https://odds.fairplay.com/widget.js | grep Content-Length
    
    # Likely output: ~150-300 KB uncompressed
    # With gzip: ~40-80 KB
    

    Reduce Bundle Size

    Strategy 1: Tree-shaking

    Only include code you actually use:

    // ❌ WRONG: Loads entire library
    import * as FairPlayWidget from '@fairplay/widget';
    
    // ✅ RIGHT: Only load what you need
    import { OddsDisplay } from '@fairplay/widget';
    

    Strategy 2: Code splitting

    Load widget code only when needed:

    // Load widget only if user is in a browser supporting it
    if (typeof Worker !== 'undefined') {
      import(/* webpackChunk: "odds-widget" */ '@fairplay/widget')
        .then(module => {
          module.initializeWidget();
        });
    }
    

    Strategy 3: Minification and compression

    Make sure widget is minified and gzipped:

    // Check if minified
    // Should be: widget.min.js
    // Should NOT be: widget.js
    
    // Check if gzip-compressed
    // Should be served with: Content-Encoding: gzip
    

    Typical Bundle Sizes

    Widget type | Uncompressed | Gzipped | Load time (3G)
    ------------|--------------|---------|----------------
    Static odds | 30 KB        | 10 KB   | 100ms
    Real-time   | 80 KB        | 25 KB   | 250ms
    Full suite  | 150 KB       | 45 KB   | 450ms
    

    Aim for <50 KB gzipped.

    Layout Shift Prevention

    Widgets cause Cumulative Layout Shift (CLS) when they load and push content around.

    Strategy 1: Reserve Space

    Tell the browser "this space will be filled" before the widget loads:

    <!-- Reserve 300x250 space for widget -->
    <div style="width: 300px; height: 250px;">
      <div id="odds-widget" style="width: 100%; height: 100%;"></div>
    </div>
    

    CSS aspect-ratio can help modern browsers:

    #odds-widget {
      width: 100%;
      aspect-ratio: 300 / 250;
    }
    

    Strategy 2: Placeholder

    Show a skeleton or placeholder while loading:

    <div id="odds-container">
      <!-- Placeholder while loading -->
      <div class="widget-skeleton">
        <div class="skeleton-bar"></div>
        <div class="skeleton-bar"></div>
        <div class="skeleton-bar"></div>
      </div>
    </div>
    
    <script>
    const container = document.getElementById('odds-container');
    
    // Lazy load widget
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting) {
        loadWidget(container);
      }
    });
    
    observer.observe(container);
    
    function loadWidget(container) {
      // Replace skeleton with widget
      container.innerHTML = '<div id="widget"></div>';
      FairPlayWidget.render(container.querySelector('#widget'), {
        eventId: 'event_123'
      });
    }
    </script>
    
    <style>
    .widget-skeleton {
      animation: loading 1s infinite;
    }
    
    .skeleton-bar {
      height: 20px;
      background: #eee;
      margin: 10px 0;
      border-radius: 4px;
    }
    
    @keyframes loading {
      0% { opacity: 0.6; }
      50% { opacity: 1; }
      100% { opacity: 0.6; }
    }
    </style>
    

    Strategy 3: Contain Layout

    Tell the browser that the widget won't affect the rest of the page:

    #odds-widget {
      contain: layout style paint;
    }
    

    This tells the browser the widget is self-contained, reducing re-calculation work.

    Mobile-Specific Optimisations

    Mobile devices have different constraints than desktop:

    Touch Performance

    // Desktop: Click events are fine
    button.addEventListener('click', handleClick);
    
    // Mobile: Use touchstart for faster response (100ms faster)
    button.addEventListener('touchstart', (e) => {
      e.preventDefault(); // Prevent ghost clicks
      handleClick();
    });
    
    // Fallback to click if no touch events
    if (!('ontouchstart' in window)) {
      button.addEventListener('click', handleClick);
    }
    

    Mobile Bandwidth Considerations

    // Detect slow networks and adapt widget behavior
    if (navigator.connection) {
      const speed = navigator.connection.effectiveType;
    
      if (speed === '4g') {
        // User on fast network, load full widget
        loadFullFeaturedWidget();
      } else if (speed === '3g' || speed === '2g') {
        // User on slow network, load lightweight version
        loadMinimalWidget();
      }
    }
    

    Mobile Form Factors

    // Responsive widget for different screen sizes
    const viewport = {
      isMobile: window.innerWidth < 768,
      isTablet: window.innerWidth >= 768 && window.innerWidth < 1024,
      isDesktop: window.innerWidth >= 1024
    };
    
    if (viewport.isMobile) {
      // Single-column layout, optimised for touch
      loadMobileOptimisedWidget();
    } else if (viewport.isTablet) {
      // Two-column layout
      loadTabletWidget();
    } else {
      // Full desktop experience with all features
      loadDesktopWidget();
    }
    

    iOS-Specific Issues

    Problem 1: Safari aggressively caches requests

    // iOS Safari sometimes caches API responses too long
    // Add cache-busting parameter
    const timestamp = Date.now();
    const url = `/api/odds?t=${timestamp}`;
    

    Problem 2: iPhone battery drain with persistent connections

    // Background tabs drain battery on mobile
    // Reduce update frequency when page is backgrounded
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        // Page backgrounded, pause updates
        pauseOddsUpdates();
      } else {
        // Page visible, resume updates
        resumeOddsUpdates();
      }
    });
    

    Real-Time Updates Without Performance Penalty

    Updating odds every 100ms sounds like it would hurt performance. Properly implemented, it doesn't.

    Strategy 1: Batch Updates

    Don't update the DOM every time odds change. Batch updates:

    class OddsDisplay {
      constructor() {
        this.pendingUpdates = [];
        this.updateScheduled = false;
      }
    
      updateOdds(odds) {
        this.pendingUpdates.push(odds);
    
        if (!this.updateScheduled) {
          this.updateScheduled = true;
          // Batch all updates that happen within 100ms into one render
          requestAnimationFrame(() => {
            this.render(this.pendingUpdates);
            this.pendingUpdates = [];
            this.updateScheduled = false;
          });
        }
      }
    
      render(updates) {
        // Only update DOM once per animation frame
        // Even if we got 10 odds updates
      }
    }
    

    Strategy 2: Virtual Scrolling

    If showing 100+ markets, only render the visible ones:

    class MarketList {
      constructor(container, markets) {
        this.container = container;
        this.markets = markets;
        this.visibleRange = { start: 0, end: 20 }; // Only render 20 at a time
      }
    
      render() {
        // Only render visible markets
        const visible = this.markets.slice(
          this.visibleRange.start,
          this.visibleRange.end
        );
    
        this.container.innerHTML = visible
          .map(market => this.renderMarket(market))
          .join('');
      }
    
      onScroll() {
        // Update visible range based on scroll position
        const scrollTop = this.container.scrollTop;
        const itemHeight = 50; // pixels
        this.visibleRange.start = Math.floor(scrollTop / itemHeight);
        this.visibleRange.end = this.visibleRange.start + 20;
    
        this.render();
      }
    }
    

    Strategy 3: Debounce Expensive Operations

    Don't recalculate everything on every update:

    class OddsWidget {
      updateOdds(odds) {
        // Quick update: just change the odds numbers
        this.updateOddsDisplay(odds);
    
        // Expensive operation: recalculate best odds
        // Debounce this to run at most once per 500ms
        this.debouncedCalculateBestOdds(odds);
      }
    
      debouncedCalculateBestOdds = debounce((odds) => {
        this.calculateBestOdds(odds);
      }, 500);
    }
    
    function debounce(fn, delay) {
      let timeoutId;
      return (...args) => {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => fn(...args), delay);
      };
    }
    

    Monitoring Widget Performance

    After deploying, measure the actual impact:

    Using Web Vitals Library

    import { getCLS, getFCP, getFID, getLCP, getTTFB } from 'web-vitals';
    
    getCLS(console.log); // Cumulative Layout Shift
    getFCP(console.log); // First Contentful Paint
    getFID(console.log); // First Input Delay (or INP)
    getLCP(console.log); // Largest Contentful Paint
    getTTFB(console.log); // Time to First Byte
    

    Custom Monitoring

    class WidgetPerformanceMonitor {
      constructor(widgetName) {
        this.widgetName = widgetName;
        this.metrics = {};
      }
    
      markStart(label) {
        this.metrics[label] = {
          start: performance.now()
        };
      }
    
      markEnd(label) {
        if (this.metrics[label]) {
          this.metrics[label].end = performance.now();
          this.metrics[label].duration =
            this.metrics[label].end - this.metrics[label].start;
    
          this.sendToAnalytics(label, this.metrics[label].duration);
        }
      }
    
      sendToAnalytics(label, duration) {
        // Send to your analytics service
        fetch('/api/analytics/widget-perf', {
          method: 'POST',
          body: JSON.stringify({
            widget: this.widgetName,
            metric: label,
            duration: duration,
            timestamp: new Date().toISOString()
          })
        });
      }
    }
    
    // Usage
    const monitor = new WidgetPerformanceMonitor('OddsWidget');
    monitor.markStart('widget_load');
    loadOddsWidget();
    monitor.markEnd('widget_load');
    

    Advanced Performance Metrics

    Measuring Real User Experience

    Web Vitals are important, but they don't tell the whole story. Track additional metrics:

    Metric 1: Time to First Odds Display

    const oddsDisplayObserver = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      entries.forEach((entry) => {
        if (entry.name === 'odds-display') {
          const ttfod = entry.startTime; // Time to first odds display
          analytics.track('ttfod', { duration: ttfod });
    
          // Alert if too slow
          if (ttfod > 3000) {
            alerts.warn('Odds display slow: ' + ttfod + 'ms');
          }
        }
      });
    });
    
    oddsDisplayObserver.observe({ entryTypes: ['measure'] });
    
    // Mark when odds appear
    performance.mark('odds-display-complete');
    performance.measure('odds-display', 'navigationStart', 'odds-display-complete');
    

    Metric 2: Odds Update Frequency

    class OddsUpdateTracker {
      constructor() {
        this.updates = [];
        this.lastUpdate = Date.now();
      }
    
      trackUpdate() {
        const now = Date.now();
        const timeSinceLastUpdate = now - this.lastUpdate;
        this.updates.push(timeSinceLastUpdate);
        this.lastUpdate = now;
    
        // Report metrics every 60 updates
        if (this.updates.length === 60) {
          const avgUpdateFrequency = this.updates.reduce((a, b) => a + b) / 60;
          analytics.track('avg_odds_update_frequency', { duration: avgUpdateFrequency });
          this.updates = [];
        }
      }
    }
    

    Metric 3: Cache Effectiveness

    class CacheMetrics {
      constructor() {
        this.hits = 0;
        this.misses = 0;
      }
    
      trackHit() {
        this.hits++;
        this.report();
      }
    
      trackMiss() {
        this.misses++;
        this.report();
      }
    
      report() {
        const total = this.hits + this.misses;
        const hitRate = this.hits / total * 100;
    
        if (total % 100 === 0) {
          analytics.track('cache_hit_rate', { percentage: hitRate });
        }
      }
    }
    

    Comparing Widget Providers

    Use these metrics to evaluate widget providers:

    Provider | TTFOD | FID | CLS | Cache Hit Rate | Cost/Month
    ---------|-------|-----|-----|-----------------|----------
    FairPlay | 800ms | 45ms | 0.08 | 95% | $5K
    Provider A | 1200ms | 120ms | 0.15 | 70% | $3K
    Provider B | 1500ms | 200ms | 0.20 | 50% | $2K
    

    FairPlay is more expensive but provides significantly better user experience.

    Real-World Performance Comparison

    Example 1: News Site with 50M Monthly Visitors

    Scenario: Odds widget on every article page

    Before optimisation:

    • Page load: 4.2s
    • LCP: 3.1s
    • FID: 85ms
    • CLS: 0.14
    • Google rank: Position 8 (page 1, but not great)

    After optimisation:

    • Page load: 2.1s (50% improvement)
    • LCP: 1.8s (42% improvement)
    • FID: 35ms (59% improvement)
    • CLS: 0.06 (57% improvement)
    • Google rank: Position 2 (competitive keyword)

    Business impact:

    • CTR increased: +35% (more users clicking from search)
    • Bounce rate decreased: -25% (users staying longer)
    • Affiliate revenue increased: +40% (more odds clicks)
    • Monthly revenue impact: +$180K

    ROI on optimisation: 12:1 (spent $15K, made $180K additional revenue)

    Example 2: Betting Guide Site

    Scenario: 5 odds widgets per page, 100K daily visitors

    Initial problem:

    • Pages with widgets loading 6-8 seconds
    • Widget timeout issues during peak hours
    • Users leaving for competitors

    Solution implemented:

    • Lazy load all widgets (only load if user scrolls to them)
    • Implement distributed cache (Redis)
    • Add CDN for widget assets
    • Bundle multiple widgets into single request

    Results:

    • Page load time: 4.2s → 1.8s
    • Widget timeouts: 5% → 0.1%
    • User retention: 65% → 82% (17% improvement)
    • Session duration: 3:42 → 6:15 (68% improvement)

    Revenue impact:

    • 100K daily visitors × 17% better retention = 17K additional users/day
    • 17K × $0.50 ARPU = $8.5K additional revenue/day
    • Annual impact: $3.1M

    Investment: $40K in optimisation work Payback period: 5 days

    This is why performance optimisation matters.

    Vendor Lock-In Prevention

    Optimise your widget architecture to avoid vendor lock-in:

    // Bad: Direct provider API calls
    async function getOdds() {
      const fairplayOdds = await FairPlayAPI.getOdds();
      renderOdds(fairplayOdds);
    }
    
    // Good: Abstraction layer
    class OddsAdapter {
      constructor(provider) {
        this.provider = provider;
      }
    
      async getOdds(eventId) {
        const data = await this.provider.fetchOdds(eventId);
        return this.normalizeToCanonicalFormat(data);
      }
    
      normalizeToCanonicalFormat(data) {
        // Convert from provider format to your internal format
        return {
          eventId: data.id,
          markets: data.markets.map(m => ({
            id: m.marketId,
            name: m.marketName,
            odds: m.outcomes
          }))
        };
      }
    }
    
    // Swap providers by changing one line
    const oddsAdapter = new OddsAdapter(new FairPlayProvider());
    // → const oddsAdapter = new OddsAdapter(new CompetitorProvider());
    

    With this pattern, switching providers requires only changing the provider class, not your entire codebase.

    Moving Forward: Optimising Your Widget Deployment

    1. Measure baseline: Load your page without widget, record all Core Web Vitals
    2. Add widget: Load with widget, measure impact
    3. Identify bottlenecks: Where did latency increase? (Usually DOM rendering or network)
    4. Optimise strategically: Use patterns from this guide (lazy loading, caching, batching)
    5. Monitor continuous: Set up Real User Monitoring (RUM) to track production performance
    6. Maintain discipline: Monitor widget updates, they might regress performance
    7. Track ROI: Measure revenue impact of performance improvements

    Odds widgets should enhance your site, not degrade it. With proper optimisation, users won't even notice the widget affecting their experience. In fact, optimised widgets drive measurable revenue increases (usually 30-50% improvement in engagement metrics).

    FairPlay's widgets are pre-optimised and use lazy loading, local caching, and request batching by default. No additional work needed beyond basic implementation.


    Related Articles:

    Share

    Ready to explore BetTech for your business?

    Talk to the FairPlay team about how our platform can work for your business.

    Get Started

    Related Articles

    Explore More Insights