This pattern sits within Session State Persistence & Hydration Fallbacks, the broader strategy for keeping UI state coherent across crashes, reloads, and connectivity gaps.

Problem Statement #

The failure mode is specific: a user works offline, accumulates queued mutations in local storage, then reconnects — and the UI locks up for several seconds while the application tries to synchronously re-fetch every stale resource before rendering. On a 4G connection with a cold cache, that can mean a 3–5 second blank or skeleton period that feels indistinguishable from a crash. On slower connections the UI never recovers and the user force-refreshes, losing any queued work.

The root cause is that most applications treat the online event as a signal to immediately and eagerly refetch everything. Without a priority queue, debouncing, or bandwidth awareness, that burst of requests saturates the connection and starves the critical-path data that would actually restore the visible UI.

Prerequisites #


Core Implementation #

The central abstraction is a ReconnectCacheWarmer that registers network event listeners, debounces connection flapping, and drains a typed priority queue of hydration tasks serially — one AbortController per reconnect cycle so that a second offline→online transition cancels the in-flight cycle cleanly.

type ConnectionState = 'offline' | 'reconnecting' | 'online';
type HydrationPriority = 'critical' | 'background' | 'deferred';

interface HydrationTask {
  id: string;
  priority: HydrationPriority;
  // Receives an AbortSignal; must reject/resolve rather than swallow errors
  executor: (signal: AbortSignal) => Promise<void>;
}

class ReconnectCacheWarmer {
  private state: ConnectionState = 'offline';
  private queue: HydrationTask[] = [];
  private activeController: AbortController | null = null;
  // 2-second window to absorb connection flapping before kicking off a cycle
  private readonly flapDebounceMs = 2_000;
  private lastTransition = 0;

  constructor() {
    this.bindNetworkEvents();
  }

  private bindNetworkEvents(): void {
    window.addEventListener('online', this.handleReconnect.bind(this));
    window.addEventListener('offline', () => this.transition('offline'));

    // Network Information API: downgrade non-critical tasks on metered/slow links
    const conn = (navigator as any).connection;
    conn?.addEventListener('change', () => {
      if (this.state === 'online' && this.isLowBandwidth()) {
        this.purgeDeferredTasks();
      }
    });
  }

  private handleReconnect(): void {
    const now = Date.now();
    // Absorb rapid offline→online→offline flapping; skip redundant warm cycles
    if (now - this.lastTransition < this.flapDebounceMs) return;
    this.lastTransition = now;

    // Cancel any existing warm cycle before starting a new one
    this.activeController?.abort();
    this.transition('reconnecting');
    this.processQueue();
  }

  private async processQueue(): Promise<void> {
    const priorityOrder: Record<HydrationPriority, number> = {
      critical: 0,
      background: 1,
      deferred: 2,
    };
    const sorted = [...this.queue].sort(
      (a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]
    );

    this.activeController = new AbortController();
    const { signal } = this.activeController;

    for (const task of sorted) {
      if (signal.aborted) break;
      try {
        // Serial execution prevents concurrent hydration layers from racing on shared state
        await task.executor(signal);
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') break;
        console.warn(`[CacheWarmer] Task "${task.id}" failed — continuing queue`, err);
      }
    }

    if (!signal.aborted) this.transition('online');
  }

  /** Register a warm task. If already online, run it immediately. */
  enqueue(task: HydrationTask): void {
    // Deduplicate by id to prevent double-registration on hot-reload
    if (this.queue.some((t) => t.id === task.id)) return;
    this.queue.push(task);
    if (this.state === 'online') this.processQueue();
  }

  /** Discard any task that is not critical — called on low-bandwidth detection */
  private purgeDeferredTasks(): void {
    this.queue = this.queue.filter((t) => t.priority === 'critical');
  }

  private transition(next: ConnectionState): void {
    this.state = next;
    window.dispatchEvent(new CustomEvent('network:state', { detail: { state: next } }));
  }

  private isLowBandwidth(): boolean {
    const conn = (navigator as any).connection;
    return conn?.effectiveType === 'slow-2g' || conn?.saveData === true;
  }

  destroy(): void {
    this.activeController?.abort();
    window.removeEventListener('online', this.handleReconnect.bind(this));
    window.removeEventListener('offline', () => this.transition('offline'));
  }
}

Architecture Note #

The serial queue design is deliberate. Concurrent warm tasks create race conditions when multiple tasks write overlapping slices of application state — for example, a user-profile task and a permissions task that both update the same Zustand or Redux store slice. Running them sequentially in priority order means critical data (auth tokens, user context) is committed before background data (activity feeds, preference objects) can overwrite shared keys.

The AbortController lifecycle is scoped to a single reconnect cycle, not to the class instance. A second online event fires a fresh controller, cancelling in-flight fetches from the previous cycle rather than letting two sets of warm tasks interleave. This mirrors the approach used in Draft Auto-Save & Recovery Workflows where per-save abort signals prevent stale saves from committing after a faster subsequent save resolves.

The state machine dispatches a network:state custom event so that framework layers — React Query’s onlineManager, SWR’s isOnline hook, Vue’s useOnline composable — can subscribe without coupling to the warmer directly.


Architecture Diagram #

Reconnect Cache Warming — State Machine and Task Priority Flow Diagram showing the offline, reconnecting, and online states with transitions, and the critical/background/deferred task priority queue draining into the IndexedDB persistent cache layer. offline queue builds online event + debounce pass reconnecting queue draining AbortController live queue empty online cache warm offline event → abort() + queue preserved Priority Queue critical background deferred processQueue() IndexedDB warm-cache store set() PerformanceObserver sendBeacon flush

Edge Cases and Gotchas #

Failure mode Symptom Mitigation
Connection flapping (offline→online in < 2 s) Multiple warm cycles start concurrently; tasks execute twice flapDebounceMs guard + abort() before every new cycle
Storage quota exceeded during bulk set() QuotaExceededError thrown mid-cycle Catch, call evictExpired(), retry once; fall back to memory-only map for non-critical keys
Concurrent tabs racing on warm tasks Two tabs fetch the same resource simultaneously BroadcastChannel leader election; only the leader tab executes network tasks
Component unmounted before warm cycle completes Dangling promise resolves and sets state on an unmounted tree Framework integration must pass the AbortSignal into the fetch; React useEffect cleanup calls abort()
SSR hydration mismatch Server-rendered HTML diverges from warm-cached client state applied before hydration Apply warm cache data only after useEffect (CSR boundary); never inject into initial render on the server
Low bandwidth degradation detected mid-cycle navigator.connection fires change; effective type drops to slow-2g purgeDeferredTasks() trims the queue; the in-flight task completes; future tasks skip network

Advanced Variant: React Query Integration #

Binding ReconnectCacheWarmer to React Query’s prefetchQuery gives framework-native deduplication and staleTime semantics on top of the priority queue.

import { useEffect, useRef } from 'react';
import { useQueryClient, type QueryKey } from '@tanstack/react-query';

interface UseReconnectPrefetchOptions {
  queryKey: QueryKey;
  queryFn: () => Promise<unknown>;
  /** Milliseconds before cached data is considered stale. Default: 5 min. */
  staleTime?: number;
  /** Skip registration when false — useful for conditional feature flags */
  enabled?: boolean;
}

export function useReconnectPrefetch({
  queryKey,
  queryFn,
  staleTime = 1_000 * 60 * 5,
  enabled = true,
}: UseReconnectPrefetchOptions): void {
  const queryClient = useQueryClient();
  const abortRef = useRef<AbortController | null>(null);

  useEffect(() => {
    if (!enabled) return;

    const handleOnline = async () => {
      // Guard: browser may fire online without actual connectivity
      if (!navigator.onLine) return;

      // Cancel any in-flight prefetch from the previous reconnect
      abortRef.current?.abort();
      abortRef.current = new AbortController();

      try {
        // prefetchQuery respects existing cache: no-ops if data is within staleTime
        await queryClient.prefetchQuery({
          queryKey,
          queryFn,
          staleTime,
          gcTime: staleTime * 2,
        });
      } catch (err) {
        if (err instanceof DOMException && err.name === 'AbortError') return;
        // Log but do not rethrow — prefetch failure is non-fatal; RQ will retry on next mount
        console.error('[useReconnectPrefetch] Failed:', { queryKey, err });
      }
    };

    window.addEventListener('online', handleOnline);
    // Run immediately if already online (e.g., hook mounted after reconnect)
    if (navigator.onLine) void handleOnline();

    return () => {
      window.removeEventListener('online', handleOnline);
      abortRef.current?.abort();
    };
  }, [enabled, queryClient, queryKey, queryFn, staleTime]);
}

Register it at the route level, not inside data-consuming leaf components, to avoid redundant registrations when multiple subscribers share the same queryKey.

Vuex / Pinia integration follows the same pattern: add a plugin that listens for network:state custom events dispatched by ReconnectCacheWarmer.transition(), then dispatches store.dispatch('rehydrate') once the warmer emits online. This keeps framework store logic decoupled from the network event layer.


Persistent Cache Layer (IndexedDB) #

Durable warm-cache storage requires TTL eviction and safe serialization. Aligning this with the LocalStorage & IndexedDB Sync Strategies pattern — where pending mutations are also staged in IndexedDB — ensures the two stores are evicted on the same schedule and don’t compete for quota.

import { openDB, type IDBPDatabase, type DBSchema } from 'idb';

interface CacheSchema extends DBSchema {
  warmCache: {
    key: string;
    value: { data: unknown; timestamp: number; ttl: number };
    indexes: { 'by-timestamp': number };
  };
}

export class PersistentCacheLayer {
  private db: Promise<IDBPDatabase<CacheSchema>>;

  constructor(dbName = 'reconnect-cache', version = 1) {
    this.db = openDB<CacheSchema>(dbName, version, {
      upgrade(db) {
        const store = db.createObjectStore('warmCache', { keyPath: 'key' });
        store.createIndex('by-timestamp', 'timestamp');
      },
    });
  }

  async set(key: string, value: unknown, ttlMs = 300_000): Promise<void> {
    const db = await this.db;
    // structuredClone rejects non-serialisable values (functions, DOM nodes, etc.)
    const safeValue = structuredClone(value);
    await db.put('warmCache', { key, data: safeValue, timestamp: Date.now(), ttl: ttlMs });
  }

  async get<T = unknown>(key: string): Promise<T | null> {
    const db = await this.db;
    const entry = await db.get('warmCache', key);
    if (!entry) return null;
    if (Date.now() - entry.timestamp > entry.ttl) {
      await db.delete('warmCache', key);
      return null;
    }
    return entry.data as T;
  }

  /** Bulk-evict expired entries before a warm cycle to reclaim quota */
  async evictExpired(): Promise<void> {
    const db = await this.db;
    const tx = db.transaction('warmCache', 'readwrite');
    let cursor = await tx.store.index('by-timestamp').openCursor();
    while (cursor) {
      const { timestamp, ttl } = cursor.value;
      if (Date.now() - timestamp > ttl) await cursor.delete();
      cursor = await cursor.continue();
    }
    await tx.done;
  }
}

Call evictExpired() as the first task in the priority queue — before any network pre-fetches — so that quota is reclaimed before new data arrives.


Testing and CI/CD Validation #

Playwright’s CDP Network.emulateNetworkConditions allows deterministic offline/online toggling inside a headless browser, making it suitable for CI:

import { test, expect, type Page } from '@playwright/test';

async function goOffline(page: Page): Promise<void> {
  const client = await page.context().newCDPSession(page);
  await client.send('Network.emulateNetworkConditions', {
    offline: true,
    latency: 0,
    downloadThroughput: 0,
    uploadThroughput: 0,
  });
}

async function goOnline(page: Page): Promise<void> {
  const client = await page.context().newCDPSession(page);
  await client.send('Network.emulateNetworkConditions', {
    offline: false,
    latency: 50,
    downloadThroughput: 500_000,
    uploadThroughput: 500_000,
  });
}

test('cache warmer populates IndexedDB on reconnect', async ({ page }) => {
  await page.goto('/dashboard');
  await goOffline(page);
  // Simulate offline interaction that queues a hydration task
  await page.evaluate(() => window.__warmer?.enqueue({
    id: 'user-profile',
    priority: 'critical',
    executor: async (signal) => {
      const res = await fetch('/api/me', { signal });
      const data = await res.json();
      await window.__cache?.set('user-profile', data);
    },
  }));

  await goOnline(page);
  // Wait for the network:state = online event
  await page.waitForFunction(() =>
    document.body.dataset.networkState === 'online', { timeout: 5_000 }
  );

  // Assert the warm-cached value is present in IndexedDB
  const cached = await page.evaluate(async () => window.__cache?.get('user-profile'));
  expect(cached).not.toBeNull();
});

For unit tests, mock window.addEventListener and navigator.onLine with vi.spyOn in Vitest. Assert that processQueue() is called exactly once when two online events fire within the flapDebounceMs window.


Telemetry and Observability #

Wrap the warm cycle with performance.mark / performance.measure so that PerformanceObserver entries appear in RUM tooling (Datadog Browser SDK, Sentry Performance, etc.) without any custom transport overhead:

export class ReconnectTelemetry {
  private buffer: Array<{ type: string; durationMs?: number; error?: string }> = [];

  markStart(): void {
    performance.mark('cache_warm_start');
  }

  markEnd(): void {
    performance.mark('cache_warm_end');
    const [entry] = performance.getEntriesByName('cache_warm_cycle');
    if (!entry) {
      performance.measure('cache_warm_cycle', 'cache_warm_start', 'cache_warm_end');
    }
    this.flush({ type: 'cache_warm_end', durationMs: performance.getEntriesByName('cache_warm_cycle')[0]?.duration });
  }

  logFailure(taskId: string, err: Error): void {
    this.flush({ type: 'prefetch_fail', error: `${taskId}: ${err.message}` });
  }

  private flush(event: { type: string; durationMs?: number; error?: string }): void {
    this.buffer.push(event);
    // Batch at 5 events or on failure — avoid per-event beacon round-trips
    const shouldFlush = this.buffer.length >= 5 || event.type === 'prefetch_fail';
    if (!shouldFlush) return;
    const payload = JSON.stringify(this.buffer);
    navigator.sendBeacon?.('/api/telemetry', payload);
    this.buffer = [];
  }
}

Key observability rules for this pattern:

  • Record state reconciliation deltas, not raw payloads — log keys, not values, to avoid leaking PII.
  • Expose a cacheHitRatio metric by counting get() hits vs. misses in PersistentCacheLayer.
  • Alert on cache_warm_cycle durations above 4 seconds: that threshold typically corresponds to the user perceiving the app as unresponsive.
  • Use requestIdleCallback (or scheduler.postTask with background priority) for telemetry flushing — never let it block the warm cycle itself.

Deep Dives #

The trickiest part of cache warming in practice is the service worker layer. When the SW intercepts API requests during a warm cycle and serves stale-while-revalidate responses, the warmer’s tasks may resolve from cache immediately while the background fetch quietly updates the cache — leaving the UI briefly inconsistent if it reads from both sources. Cache warming strategies after network drops in PWAs covers the SW fetch handler patterns, cache versioning, and the coordination protocol between the SW and the page’s warm cycle.


Frequently Asked Questions #

How do we prevent cache warming from blocking initial render after reconnect? Run all warming tasks off the critical path using requestIdleCallback or a scheduler that yields between tasks. Mark warming tasks as background or deferred priority unless they back a component that is already mounted and waiting for data.

What is the safest way to handle hydration mismatches during rapid reconnect cycles? Lock the UI into a read-only reconciling state until the priority queue drains or explicitly fails. Only apply server-authoritative data once the full warm cycle completes — this aligns with the approach described in Hydration Mismatch State Recovery, which covers the broader SSR/CSR reconciliation problem.

When should pre-fetching be bypassed in favour of serving from local cache only? Bypass network pre-fetching when navigator.connection.saveData is true, effectiveType is slow-2g, or a getBattery() check shows critical charge. Serve from IndexedDB and re-queue the fetch for the next idle window.

How do we keep multiple open tabs from racing during cache warming? Use BroadcastChannel to elect a leader tab via a lock-acquire message. Only the leader executes network pre-fetches; sibling tabs listen for a cache:warmed event and read from IndexedDB directly.

How do we validate cache warming reliability in CI? Use Playwright’s offline/online CDP toggles to simulate flapping, then assert that the warmed query key exists in the query client’s cache within a timeout. Inject a mock sendBeacon spy to confirm telemetry events are emitted without blocking the warm cycle.