Steve Kinney

Speculation Rules & bfcache Optimization

You click a link. The page loads instantly. You hit the back button. The previous page appears immediately, exactly as you left it. No spinners, no re-rendering, no waiting. This isn’t magic—it’s what happens when you properly leverage the Speculation Rules API and the back/forward cache (bfcache).

Most React apps break these powerful browser features without even knowing it. They prevent bfcache with careless event listeners. They ignore speculation rules that could prerender entire pages. They treat navigation like it’s 2010, making users wait for every single page transition.

Let’s fix that. Let’s build React apps that navigate so fast, users think they’re using a native app.

Understanding Speculation Rules API

The Speculation Rules API lets you tell the browser which pages to prerender or prefetch:

interface SpeculationRules {
  prerender?: Rule[];
  prefetch?: Rule[];
}

interface Rule {
  source: 'list' | 'document';
  urls?: string[];
  where?: DocumentRule;
  eagerness?: 'immediate' | 'eager' | 'moderate' | 'conservative';
  requires?: string[];
}

// Basic implementation
const addSpeculationRules = (rules: SpeculationRules) => {
  if (!HTMLScriptElement.supports?.('speculationrules')) {
    console.warn('Speculation Rules API not supported');
    return;
  }

  const script = document.createElement('script');
  script.type = 'speculationrules';
  script.textContent = JSON.stringify(rules);
  document.head.appendChild(script);
};

Implementing Smart Speculation

Static Speculation Rules

Define rules for common navigation patterns:

const StaticSpeculation: React.FC = () => {
  useEffect(() => {
    // Only add rules if API is supported
    if (!HTMLScriptElement.supports?.('speculationrules')) {
      return;
    }

    const rules = {
      prerender: [
        {
          source: 'list',
          urls: ['/dashboard', '/profile'],
          eagerness: 'moderate',
        },
      ],
      prefetch: [
        {
          source: 'document',
          where: {
            and: [
              { href_matches: '/*' },
              { not: { href_matches: '/logout' } },
              { not: { href_matches: '/api/*' } },
            ],
          },
          eagerness: 'conservative',
        },
      ],
    };

    const script = document.createElement('script');
    script.type = 'speculationrules';
    script.textContent = JSON.stringify(rules);
    document.head.appendChild(script);

    return () => {
      script.remove();
    };
  }, []);

  return null;
};

Dynamic Speculation Based on User Behavior

Predict and prerender based on user patterns:

class NavigationPredictor {
  private history: string[] = [];
  private patterns: Map<string, Map<string, number>> = new Map();

  recordNavigation(from: string, to: string) {
    this.history.push(to);
    if (this.history.length > 100) {
      this.history.shift();
    }

    // Update patterns
    if (!this.patterns.has(from)) {
      this.patterns.set(from, new Map());
    }

    const destinations = this.patterns.get(from)!;
    destinations.set(to, (destinations.get(to) || 0) + 1);
  }

  predictNext(currentPage: string, threshold: number = 0.3): string[] {
    const destinations = this.patterns.get(currentPage);
    if (!destinations) return [];

    const total = Array.from(destinations.values()).reduce((a, b) => a + b, 0);
    const predictions: Array<[string, number]> = [];

    for (const [url, count] of destinations) {
      const probability = count / total;
      if (probability >= threshold) {
        predictions.push([url, probability]);
      }
    }

    return predictions.sort((a, b) => b[1] - a[1]).map(([url]) => url);
  }
}

const usePredictiveSpeculation = () => {
  const location = useLocation();
  const predictorRef = useRef(new NavigationPredictor());
  const [speculating, setSpeculating] = useState<string[]>([]);

  useEffect(() => {
    const predictor = predictorRef.current;
    const currentPath = location.pathname;

    // Record navigation
    const previousPath = sessionStorage.getItem('previousPath');
    if (previousPath) {
      predictor.recordNavigation(previousPath, currentPath);
    }
    sessionStorage.setItem('previousPath', currentPath);

    // Predict next pages
    const predictions = predictor.predictNext(currentPath);

    if (predictions.length > 0 && HTMLScriptElement.supports?.('speculationrules')) {
      // Remove old speculation rules
      document.querySelectorAll('script[type="speculationrules"]').forEach((s) => s.remove());

      // Add new rules
      const script = document.createElement('script');
      script.type = 'speculationrules';
      script.textContent = JSON.stringify({
        prerender: [
          {
            source: 'list',
            urls: predictions.slice(0, 2), // Prerender top 2 predictions
            eagerness: 'moderate',
          },
        ],
        prefetch: [
          {
            source: 'list',
            urls: predictions.slice(2, 5), // Prefetch next 3
            eagerness: 'conservative',
          },
        ],
      });
      document.head.appendChild(script);

      setSpeculating(predictions);
    }
  }, [location]);

  return { speculating };
};

Back/Forward Cache (bfcache) Optimization

The bfcache stores complete page snapshots for instant back/forward navigation:

Understanding bfcache Blockers

// ❌ Common bfcache blockers
const BfcacheBlocker: React.FC = () => {
  useEffect(() => {
    // Unload event blocks bfcache
    window.addEventListener('unload', () => {
      console.log('Page unloading');
    });

    // Open connections block bfcache
    const ws = new WebSocket('wss://example.com');

    // IndexedDB transactions can block
    const request = indexedDB.open('mydb');
  }, []);

  return <div>This component blocks bfcache!</div>;
};

// ✅ bfcache-friendly implementation
const BfcacheFriendly: React.FC = () => {
  useEffect(() => {
    // Use pagehide instead of unload
    const handlePageHide = (e: PageTransitionEvent) => {
      if (e.persisted) {
        // Page is being cached
        console.log('Page entering bfcache');
      }
    };

    // Use pageshow to detect restoration
    const handlePageShow = (e: PageTransitionEvent) => {
      if (e.persisted) {
        // Page restored from bfcache
        console.log('Page restored from bfcache');
        // Refresh any stale data
        refreshData();
      }
    };

    window.addEventListener('pagehide', handlePageHide);
    window.addEventListener('pageshow', handlePageShow);

    return () => {
      window.removeEventListener('pagehide', handlePageHide);
      window.removeEventListener('pageshow', handlePageShow);
    };
  }, []);

  return <div>bfcache optimized!</div>;
};

Managing WebSocket Connections

Close connections properly for bfcache:

const useBfcacheWebSocket = (url: string) => {
  const wsRef = useRef<WebSocket | null>(null);
  const [connected, setConnected] = useState(false);

  const connect = useCallback(() => {
    if (wsRef.current?.readyState === WebSocket.OPEN) return;

    wsRef.current = new WebSocket(url);
    wsRef.current.onopen = () => setConnected(true);
    wsRef.current.onclose = () => setConnected(false);
  }, [url]);

  const disconnect = useCallback(() => {
    wsRef.current?.close();
    wsRef.current = null;
    setConnected(false);
  }, []);

  useEffect(() => {
    connect();

    // Handle bfcache events
    const handlePageHide = (e: PageTransitionEvent) => {
      if (e.persisted) {
        // Entering bfcache - close connection
        disconnect();
      }
    };

    const handlePageShow = (e: PageTransitionEvent) => {
      if (e.persisted) {
        // Restored from bfcache - reconnect
        connect();
      }
    };

    window.addEventListener('pagehide', handlePageHide);
    window.addEventListener('pageshow', handlePageShow);

    return () => {
      disconnect();
      window.removeEventListener('pagehide', handlePageHide);
      window.removeEventListener('pageshow', handlePageShow);
    };
  }, [connect, disconnect]);

  return { connected, reconnect: connect };
};

Handling Timers and Intervals

Pause and resume timers for bfcache:

const useBfcacheTimer = (callback: () => void, delay: number) => {
  const savedCallback = useRef(callback);
  const intervalRef = useRef<NodeJS.Timeout | null>(null);
  const [isPaused, setIsPaused] = useState(false);

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  const start = useCallback(() => {
    if (intervalRef.current) return;

    intervalRef.current = setInterval(() => {
      savedCallback.current();
    }, delay);
    setIsPaused(false);
  }, [delay]);

  const stop = useCallback(() => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
      setIsPaused(true);
    }
  }, []);

  useEffect(() => {
    start();

    const handlePageHide = (e: PageTransitionEvent) => {
      if (e.persisted) {
        stop(); // Pause timer when entering bfcache
      }
    };

    const handlePageShow = (e: PageTransitionEvent) => {
      if (e.persisted) {
        start(); // Resume timer when restored
      }
    };

    window.addEventListener('pagehide', handlePageHide);
    window.addEventListener('pageshow', handlePageShow);

    return () => {
      stop();
      window.removeEventListener('pagehide', handlePageHide);
      window.removeEventListener('pageshow', handlePageShow);
    };
  }, [start, stop]);

  return { isPaused, start, stop };
};

Testing bfcache Eligibility

Build tools to test and monitor bfcache:

class BfcacheAnalyzer {
  private blockers: string[] = [];

  analyze(): { eligible: boolean; blockers: string[] } {
    this.blockers = [];

    // Check for unload handlers
    if (window.onunload !== null) {
      this.blockers.push('unload event handler');
    }

    // Check for beforeunload handlers
    if (window.onbeforeunload !== null) {
      this.blockers.push('beforeunload event handler');
    }

    // Check for open IndexedDB connections
    this.checkIndexedDB();

    // Check for active WebSockets
    this.checkWebSockets();

    // Check for pending network requests
    this.checkPendingRequests();

    return {
      eligible: this.blockers.length === 0,
      blockers: this.blockers
    };
  }

  private checkIndexedDB() {
    // Check if any IndexedDB databases are open
    if ('databases' in indexedDB) {
      indexedDB.databases().then(databases => {
        if (databases.length > 0) {
          this.blockers.push(`Open IndexedDB: ${databases.map(db => db.name).join(', ')}`);
        }
      });
    }
  }

  private checkWebSockets() {
    // Monitor WebSocket constructor
    const originalWebSocket = window.WebSocket;
    let activeConnections = 0;

    window.WebSocket = new Proxy(originalWebSocket, {
      construct(target, args) {
        activeConnections++;
        const ws = new target(...args);

        const originalClose = ws.close;
        ws.close = function(...args) {
          activeConnections--;
          return originalClose.apply(this, args);
        };

        return ws;
      }
    });

    if (activeConnections > 0) {
      this.blockers.push(`${activeConnections} active WebSocket connections`);
    }
  }

  private checkPendingRequests() {
    // Check for pending fetch requests
    const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
    const pending = resources.filter(r => r.responseEnd === 0);

    if (pending.length > 0) {
      this.blockers.push(`${pending.length} pending network requests`);
    }
  }
}

// React component for monitoring
const BfcacheMonitor: React.FC = () => {
  const [analysis, setAnalysis] = useState<{ eligible: boolean; blockers: string[] } | null>(null);

  useEffect(() => {
    const analyzer = new BfcacheAnalyzer();

    const check = () => {
      const result = analyzer.analyze();
      setAnalysis(result);
    };

    check();
    const interval = setInterval(check, 1000);

    return () => clearInterval(interval);
  }, []);

  if (!analysis) return null;

  return (
    <div className={`bfcache-monitor ${analysis.eligible ? 'eligible' : 'blocked'}`}>
      <h3>bfcache Status: {analysis.eligible ? '✅ Eligible' : '❌ Blocked'}</h3>
      {analysis.blockers.length > 0 && (
        <ul>
          {analysis.blockers.map((blocker, i) => (
            <li key={i}>{blocker}</li>
          ))}
        </ul>
      )}
    </div>
  );
};

Monitoring Speculation Success

Track whether speculation is working:

class SpeculationMonitor {
  private speculatedUrls = new Set<string>();
  private hits = 0;
  private misses = 0;

  recordSpeculation(urls: string[]) {
    urls.forEach((url) => this.speculatedUrls.add(url));
  }

  recordNavigation(url: string) {
    if (this.speculatedUrls.has(url)) {
      this.hits++;
      console.log(`✅ Speculation hit: ${url}`);
    } else {
      this.misses++;
      console.log(`❌ Speculation miss: ${url}`);
    }
  }

  getMetrics() {
    const total = this.hits + this.misses;
    return {
      hits: this.hits,
      misses: this.misses,
      hitRate: total > 0 ? this.hits / total : 0,
      speculatedCount: this.speculatedUrls.size,
    };
  }
}

const useSpeculationMonitor = () => {
  const monitorRef = useRef(new SpeculationMonitor());
  const location = useLocation();

  useEffect(() => {
    // Record navigation
    monitorRef.current.recordNavigation(location.pathname);

    // Report metrics
    const metrics = monitorRef.current.getMetrics();
    if (metrics.hits + metrics.misses > 10) {
      console.log('Speculation metrics:', metrics);

      // Send to analytics
      if (metrics.hitRate < 0.5) {
        console.warn('Low speculation hit rate - consider adjusting strategy');
      }
    }
  }, [location]);

  return monitorRef.current;
};

Advanced Speculation Patterns

Viewport-Based Speculation

Speculate on links visible in viewport:

const useViewportSpeculation = () => {
  const [visibleLinks, setVisibleLinks] = useState<string[]>([]);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        const visible = entries
          .filter((e) => e.isIntersecting)
          .map((e) => (e.target as HTMLAnchorElement).href)
          .filter((href) => href.startsWith(window.location.origin));

        setVisibleLinks((prev) => [...new Set([...prev, ...visible])]);
      },
      { rootMargin: '50px' },
    );

    document.querySelectorAll('a[href]').forEach((link) => {
      observer.observe(link);
    });

    return () => observer.disconnect();
  }, []);

  useEffect(() => {
    if (visibleLinks.length === 0) return;
    if (!HTMLScriptElement.supports?.('speculationrules')) return;

    const script = document.createElement('script');
    script.type = 'speculationrules';
    script.textContent = JSON.stringify({
      prefetch: [
        {
          source: 'list',
          urls: visibleLinks.slice(0, 10), // Limit to 10
          eagerness: 'conservative',
        },
      ],
    });

    document.head.appendChild(script);

    return () => script.remove();
  }, [visibleLinks]);
};

Time-Based Speculation

Increase speculation eagerness over time:

const useProgressiveSpeculation = () => {
  const [timeOnPage, setTimeOnPage] = useState(0);
  const location = useLocation();

  useEffect(() => {
    const startTime = Date.now();
    const interval = setInterval(() => {
      setTimeOnPage(Date.now() - startTime);
    }, 1000);

    return () => clearInterval(interval);
  }, [location]);

  useEffect(() => {
    if (!HTMLScriptElement.supports?.('speculationrules')) return;

    let eagerness: 'conservative' | 'moderate' | 'eager';

    if (timeOnPage < 3000) {
      eagerness = 'conservative';
    } else if (timeOnPage < 10000) {
      eagerness = 'moderate';
    } else {
      eagerness = 'eager';
    }

    const script = document.createElement('script');
    script.type = 'speculationrules';
    script.textContent = JSON.stringify({
      prefetch: [
        {
          source: 'document',
          where: { href_matches: '/*' },
          eagerness,
        },
      ],
    });

    document.head.appendChild(script);

    return () => script.remove();
  }, [timeOnPage]);
};

Integration with React Router

Integrate speculation with React Router:

const SpeculativeRouter: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [routes, setRoutes] = useState<string[]>([]);

  useEffect(() => {
    // Extract all routes from React Router
    const extractRoutes = (element: React.ReactElement): string[] => {
      const paths: string[] = [];

      React.Children.forEach(element.props.children, (child) => {
        if (React.isValidElement(child) && child.props.path) {
          paths.push(child.props.path);
        }
      });

      return paths;
    };

    // Add speculation rules for all routes
    if (HTMLScriptElement.supports?.('speculationrules')) {
      const script = document.createElement('script');
      script.type = 'speculationrules';
      script.textContent = JSON.stringify({
        prefetch: [{
          source: 'list',
          urls: routes,
          eagerness: 'moderate'
        }]
      });
      document.head.appendChild(script);
    }
  }, [routes]);

  return <>{children}</>;
};

Best Practices Checklist

Enable bfcache:

  • Use pagehide/pageshow instead of unload
  • Close connections before caching
  • Pause timers and intervals
  • Clear sensitive data

Implement smart speculation:

  • Start conservative, increase eagerness
  • Base on user behavior patterns
  • Respect device capabilities
  • Monitor hit rates

Test thoroughly:

  • Check bfcache eligibility
  • Monitor speculation success
  • Test on real devices
  • Measure actual impact

Handle edge cases:

  • Reconnect WebSockets on restore
  • Refresh stale data
  • Update timestamps
  • Clear outdated speculation

Respect resources:

  • Limit concurrent prerenders
  • Consider data saver mode
  • Adapt to network conditions
  • Clean up unused speculation

Last modified on .