How to Lazy Load reCAPTCHA? Optimizing Website Performance

December 03, 2025 2 Views
How to Lazy Load reCAPTCHA? Optimizing Website Performance

The Complete Guide to Lazy Loading reCAPTCHA for Maximum Performance

While reCAPTCHA is essential for security, its impact on page speed can be significant—especially for Core Web Vitals like Largest Contentful Paint (LCP). This comprehensive guide shows you how to implement lazy loading for reCAPTCHA without compromising security or user experience.


Why Lazy Load reCAPTCHA?

The Performance Problem:

  • reCAPTCHA v2/v3 scripts: ~100-300KB additional page weight

  • Multiple network requests: Scripts, styles, and resources

  • Render-blocking potential: Can delay LCP by 1-3 seconds

  • Mobile impact: More severe on slower connections

The Solution:

Load reCAPTCHA only when needed—typically when a user begins interacting with a form. This can improve LCP by 15-40% on forms-heavy pages.


Implementation Approaches

Method 1: Basic Focus-Based Lazy Load (Recommended)

Load reCAPTCHA when user interacts with any form field.

javascript
// reCAPTCHA Lazy Loader - Basic Version
let reCaptchaLoaded = false;
const formFields = document.querySelectorAll('input, textarea, select');

formFields.forEach(field => {
    field.addEventListener('focus', loadReCaptchaOnDemand);
    field.addEventListener('click', loadReCaptchaOnDemand);
});

function loadReCaptchaOnDemand() {
    if (!reCaptchaLoaded) {
        // Load reCAPTCHA script
        const script = document.createElement('script');
        script.src = 'https://www.google.com/recaptcha/api.js';
        script.async = true;
        script.defer = true;
        document.head.appendChild(script);
        
        // Mark as loaded
        reCaptchaLoaded = true;
        
        // Clean up event listeners (optional)
        formFields.forEach(field => {
            field.removeEventListener('focus', loadReCaptchaOnDemand);
            field.removeEventListener('click', loadReCaptchaOnDemand);
        });
        
        console.log('reCAPTCHA loaded on demand');
    }
}

Method 2: Advanced Scroll-Based Lazy Load

Load reCAPTCHA when form becomes visible in viewport.

javascript
// Intersection Observer for Viewport Detection
let reCaptchaLoaded = false;

const observerOptions = {
    root: null,
    rootMargin: '100px', // Load 100px before entering viewport
    threshold: 0.1
};

const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting && !reCaptchaLoaded) {
            loadReCaptcha();
            observer.unobserve(entry.target);
        }
    });
}, observerOptions);

// Observe your form container
const formContainer = document.getElementById('contact-form');
if (formContainer) {
    observer.observe(formContainer);
}

function loadReCaptcha() {
    const script = document.createElement('script');
    script.src = 'https://www.google.com/recaptcha/api.js';
    script.async = true;
    script.defer = true;
    script.onload = () => {
        reCaptchaLoaded = true;
        console.log('reCAPTCHA loaded via IntersectionObserver');
    };
    document.head.appendChild(script);
}

Method 3: Hybrid Approach (Focus + Scroll)

Best of both worlds—loads when form is visible OR when user interacts.

javascript
// Hybrid Lazy Loader
class ReCaptchaLazyLoader {
    constructor() {
        this.loaded = false;
        this.observed = false;
        this.form = document.querySelector('form');
        
        this.init();
    }
    
    init() {
        if (!this.form) return;
        
        // Method A: Observe form visibility
        this.setupIntersectionObserver();
        
        // Method B: Listen for form interactions
        this.setupInteractionListeners();
        
        // Fallback: Load after 5 seconds if no interaction
        this.setupFallback();
    }
    
    setupIntersectionObserver() {
        if ('IntersectionObserver' in window) {
            const observer = new IntersectionObserver((entries) => {
                if (entries[0].isIntersecting) {
                    this.load();
                    observer.unobserve(this.form);
                }
            }, { threshold: 0.1 });
            
            observer.observe(this.form);
            this.observed = true;
        }
    }
    
    setupInteractionListeners() {
        const fields = this.form.querySelectorAll('input, textarea, select');
        fields.forEach(field => {
            field.addEventListener('focus', () => this.load());
            field.addEventListener('click', () => this.load());
        });
    }
    
    setupFallback() {
        setTimeout(() => {
            if (!this.loaded && this.form.getBoundingClientRect().top < window.innerHeight) {
                this.load();
            }
        }, 5000);
    }
    
    load() {
        if (this.loaded) return;
        
        const script = document.createElement('script');
        script.src = 'https://www.google.com/recaptcha/api.js';
        script.async = true;
        script.defer = true;
        
        script.onload = () => {
            this.loaded = true;
            this.onLoadCallback();
        };
        
        document.head.appendChild(script);
    }
    
    onLoadCallback() {
        // Initialize your reCAPTCHA here
        console.log('reCAPTCHA ready for initialization');
        // Example: grecaptcha.ready(() => { ... });
    }
}

// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
    new ReCaptchaLazyLoader();
});

reCAPTCHA v3 vs v2 Implementation

For reCAPTCHA v3 (Invisible):

javascript
// Lazy load v3 with token generation on form submit
async function loadAndExecuteReCaptchaV3(action = 'submit') {
    // Load script if not loaded
    if (typeof grecaptcha === 'undefined') {
        await new Promise((resolve) => {
            const script = document.createElement('script');
            script.src = 'https://www.google.com/recaptcha/api.js?render=YOUR_SITE_KEY';
            script.onload = resolve;
            document.head.appendChild(script);
        });
    }
    
    // Execute reCAPTCHA
    return new Promise((resolve) => {
        grecaptcha.ready(() => {
            grecaptcha.execute('YOUR_SITE_KEY', { action: action })
                .then(token => resolve(token));
        });
    });
}

// Usage in form submission
document.getElementById('myForm').addEventListener('submit', async (e) => {
    e.preventDefault();
    
    const token = await loadAndExecuteReCaptchaV3();
    
    // Add token to form and submit
    const input = document.createElement('input');
    input.type = 'hidden';
    input.name = 'g-recaptcha-response';
    input.value = token;
    e.target.appendChild(input);
    
    // Continue with form submission
    e.target.submit();
});

For reCAPTCHA v2 (Checkbox):

javascript
// Lazy load v2 with checkbox rendering
function loadAndRenderReCaptchaV2() {
    if (typeof grecaptcha === 'undefined') {
        const script = document.createElement('script');
        script.src = 'https://www.google.com/recaptcha/api.js';
        script.onload = () => renderReCaptcha();
        document.head.appendChild(script);
    } else {
        renderReCaptcha();
    }
}

function renderReCaptcha() {
    const container = document.getElementById('recaptcha-container');
    if (container && !container.querySelector('.g-recaptcha')) {
        grecaptcha.render(container, {
            sitekey: 'YOUR_SITE_KEY',
            theme: 'light', // or 'dark'
            size: 'normal' // or 'compact'
        });
    }
}

// Trigger on form interaction
document.getElementById('message').addEventListener('focus', loadAndRenderReCaptchaV2);

Performance Optimizations

1. Preconnect for Faster Loading

Add to your HTML :

html
<link rel="preconnect" href="https://www.google.com">
<link rel="preconnect" href="https://www.gstatic.com" crossorigin>

2. Resource Hints

html
<link rel="dns-prefetch" href="//www.google.com">
<link rel="preload" as="script" href="https://www.google.com/recaptcha/api.js" 
      onload="this.onload=null;this.rel='prefetch'">

3. Adaptive Loading Based on Connection

javascript
function shouldLoadReCaptchaEarly() {
    const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
    
    if (connection) {
        // Load early on fast connections
        if (connection.effectiveType === '4g' || 
            connection.saveData === false ||
            (connection.downlink && connection.downlink > 3)) { // >3 Mbps
            return true;
        }
    }
    
    // Default to lazy loading
    return false;
}

if (shouldLoadReCaptchaEarly()) {
    // Load reCAPTCHA immediately on fast connections
    loadReCaptcha();
} else {
    // Use lazy loading on slower connections
    setupLazyLoading();
}

Error Handling & Edge Cases

1. Network Error Handling

javascript
function loadReCaptchaWithRetry(retries = 3, delay = 1000) {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = 'https://www.google.com/recaptcha/api.js';
        script.async = true;
        
        script.onload = resolve;
        script.onerror = () => {
            if (retries > 0) {
                setTimeout(() => {
                    loadReCaptchaWithRetry(retries - 1, delay * 2)
                        .then(resolve)
                        .catch(reject);
                }, delay);
            } else {
                reject(new Error('Failed to load reCAPTCHA after multiple attempts'));
            }
        };
        
        document.head.appendChild(script);
    });
}

2. Form Submission Before reCAPTCHA Loads

javascript
document.getElementById('contact-form').addEventListener('submit', async (e) => {
    e.preventDefault();
    
    // Show loading state
    const submitBtn = e.target.querySelector('button[type="submit"]');
    const originalText = submitBtn.textContent;
    submitBtn.textContent = 'Verifying...';
    submitBtn.disabled = true;
    
    try {
        // Ensure reCAPTCHA is loaded
        if (typeof grecaptcha === 'undefined') {
            await loadReCaptchaWithRetry();
            await new Promise(resolve => grecaptcha.ready(resolve));
        }
        
        // Execute reCAPTCHA
        const token = await grecaptcha.execute('YOUR_SITE_KEY', { action: 'submit' });
        
        // Proceed with form submission
        // ... your submission logic
        
    } catch (error) {
        console.error('reCAPTCHA error:', error);
        // Fallback: Use alternative validation or show error
        alert('Security verification failed. Please try again.');
        submitBtn.textContent = originalText;
        submitBtn.disabled = false;
    }
});

Testing & Measurement

Performance Testing Script:

javascript
// Measure reCAPTCHA impact on LCP
const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        if (entry.name === 'reCAPTCHA-load') {
            console.log(`reCAPTCHA loaded in ${entry.duration}ms`);
            console.log(`LCP before reCAPTCHA: ${window.lcpBeforeReCaptcha}`);
        }
    }
});

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

// Mark LCP time before reCAPTCHA
window.lcpBeforeReCaptcha = performance.now();

// Start measurement when lazy loading begins
performance.mark('reCAPTCHA-start');
// ... after loading completes
performance.mark('reCAPTCHA-end');
performance.measure('reCAPTCHA-load', 'reCAPTCHA-start', 'reCAPTCHA-end');

Core Web Vitals Monitoring:

javascript
// Track CLS impact
let clsBefore, clsAfter;

function measureCLSImpact() {
    const observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
            if (entry.name === 'layout-shift' && entry.hadRecentInput === false) {
                if (!clsBefore) clsBefore = entry.value;
                else clsAfter = entry.value;
            }
        }
    });
    
    observer.observe({ entryTypes: ['layout-shift'] });
}

Framework-Specific Implementations

React Component:

jsx
import { useEffect, useRef, useState } from 'react';

function LazyReCaptcha({ siteKey, onLoad }) {
    const [loaded, setLoaded] = useState(false);
    const formRef = useRef(null);
    
    useEffect(() => {
        const form = formRef.current;
        if (!form) return;
        
        const loadReCaptcha = () => {
            if (!loaded && typeof window.grecaptcha === 'undefined') {
                const script = document.createElement('script');
                script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
                script.async = true;
                script.onload = () => {
                    setLoaded(true);
                    onLoad?.();
                };
                document.head.appendChild(script);
            }
        };
        
        // Add event listeners to all form inputs
        const inputs = form.querySelectorAll('input, textarea');
        inputs.forEach(input => {
            input.addEventListener('focus', loadReCaptcha, { once: true });
        });
        
        return () => {
            inputs.forEach(input => {
                input.removeEventListener('focus', loadReCaptcha);
            });
        };
    }, [siteKey, loaded, onLoad]);
    
    return <div ref={formRef}>{/* Your form content */}div>;
}

Vue.js Directive:

javascript
// Vue directive for lazy loading reCAPTCHA
export const lazyRecaptcha = {
    mounted(el, binding) {
        const { siteKey, onLoad } = binding.value;
        let loaded = false;
        
        const loadScript = () => {
            if (!loaded && typeof grecaptcha === 'undefined') {
                const script = document.createElement('script');
                script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
                script.async = true;
                script.onload = () => {
                    loaded = true;
                    onLoad?.();
                };
                document.head.appendChild(script);
            }
        };
        
        // Add event listeners to all interactive elements
        const elements = el.querySelectorAll('input, textarea, select, button');
        elements.forEach(element => {
            element.addEventListener('focus', loadScript, { once: true });
            element.addEventListener('click', loadScript, { once: true });
        });
    }
};

Best Practices Summary

Do:

✅ Test on slow 3G connections to ensure usability
✅ Provide visual feedback while reCAPTCHA loads
✅ Implement fallback mechanisms for network failures
✅ Monitor Core Web Vitals before/after implementation
✅ Use appropriate triggers (focus, scroll, or hybrid)
✅ Consider connection-aware loading for fast networks

Don't:

❌ Block form submission if reCAPTCHA fails to load
❌ Forget to test accessibility (screen readers, keyboard nav)
❌ Ignore mobile performance - test on actual devices
❌ Load reCAPTCHA on every page - only where needed
❌ Sacrifice security for performance - find the balance


Share this article