The Widget Performance Problem
Your site gets 50M monthly visitors. You embed an odds widget from a data provider. Suddenly:
- Page load time increases from 2.5 seconds to 4.2 seconds
- Largest Contentful Paint (LCP) grows from 1.8s to 3.1s
- Cumulative Layout Shift (CLS) jumps because the widget loads and causes content to shift
- Bounce rate increases by 12%
- SEO ranking drops (Google penalizes slow pages)
- 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
- Measure baseline: Load your page without widget, record all Core Web Vitals
- Add widget: Load with widget, measure impact
- Identify bottlenecks: Where did latency increase? (Usually DOM rendering or network)
- Optimise strategically: Use patterns from this guide (lazy loading, caching, batching)
- Monitor continuous: Set up Real User Monitoring (RUM) to track production performance
- Maintain discipline: Monitor widget updates, they might regress performance
- 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:
Ready to explore BetTech for your business?
Talk to the FairPlay team about how our platform can work for your business.
Get Started








