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.
// 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.
// 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.
// 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):
// 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):
// 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 :
<link rel="preconnect" href="https://www.google.com"> <link rel="preconnect" href="https://www.gstatic.com" crossorigin>
2. Resource Hints
<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
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
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
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:
// 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:
// 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:
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:
// 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