Skip to main content
E-commerce Platform·Software Engineer — Web Platform·2023

Case Study

GraphQL IDBLink

Persistent IndexedDB Caching Layer for Apollo Client

Apollo ClientIndexedDBTypeScriptReactGraphQL

Impact

~75% reduction in network requests for returning users

Redundant Homepage Network Requests

Impact

~10x faster for cached operations

Perceived Data Load Time (slow 3G)

Impact

~10 lines of code across 3 files, zero runtime cost

SSR/CSR Cache Staleness Fix

01

The Problem We Needed to Solve

Picture this: You're a shopper browsing an e-commerce site on your morning commute. You scroll through personalized recommendations, check out daily deals, maybe save a few items. Then your train arrives. Later that evening, you reopen the site — only to wait... and wait... while the exact same content loads all over again.

This was the daily frustration faced by millions of users on our platform's homepage. Apollo's InMemoryCache works great during a single session, but it's like writing notes in disappearing ink — the moment you refresh the page, everything vanishes.

What We Were Up Against

  • 1Session-only caching:Apollo's InMemoryCache is blazingly fast but amnesiac — every page reload starts from zero.
  • 2Redundant network requests: Users were re-downloading identical data repeatedly, creating unnecessary server load.
  • 3Mobile user pain:Users on spotty 3G connections waited 200-800ms for GraphQL responses identical to what they'd seen an hour ago.
  • 4The SSR handoff problem: Server-rendered content could become stale artifacts that persisted across render modes.

Why Existing Solutions Fell Short

SolutionThe Dealbreaker
Apollo InMemoryCacheAmnesiac — everything resets on page reload. Great for one session, useless for persistence.
apollo-cache-persistDumps entire cache into localStorage synchronously. Blocks the main thread with large caches.
Service WorkersSledgehammers where we needed scalpels. Cache entire HTTP responses, not individual GraphQL ops.
HTTP Cache-ControlServer-driven with no client flexibility. Our mixed data made header-based approaches impractical.

The Business Case

Our homepage wasn't just another page — it was the gateway to the entire shopping experience, handling millions of visits daily. In e-commerce, speed directly translates to revenue. Every 100ms of delay can measurably drop conversion rates.

Beyond UX, there was a real infrastructure cost story: redundant GraphQL queries were eating up CDN bandwidth, origin server capacity, and compute resources. We were paying to send the same bytes over and over again.

02

How We Built the Solution

We built IDBLink— a three-link chain that intercepts GraphQL operations before they hit the network. Think of it as a smart bouncer at a club: it checks if you're on the list (cache), and if so, lets you skip the line entirely.

The system sits transparently inside Apollo's link chain, using the browser's IndexedDB as a persistent second-tier cache. When a query opts in, we generate a unique hash key, check if valid cached data exists, and either serve instantly from IndexedDB or let the request proceed to the server.

Why IndexedDB?

Storage OptionCapacityPersistencePerformanceAsync
localStorage~5-10MB✅ YesSynchronous, blocking❌ No
sessionStorage~5-10MB❌ NoSynchronous, blocking❌ No
Memory CacheLimited by RAM❌ NoFast❌ No
IndexedDB~60% of disk✅ YesAsync, non-blocking✅ Yes

Link Chain Overview

Core Links

auth · error · retry

IDBManageLink

Gate — generate key, set context flags

idb_enabled

IDBPrecheckLink

Check: has(key, ttl)

ready

IDBLink

from IndexedDB

Cache Hit → instant

not ready

HTTP Link

+ write IndexedDB

Cache Miss → network

disabled

HTTP Link

Standard network request

Data Flow

useQuery with idb context

IDBManageLink

  1. 01Check APOLLO_IDB_CACHE_ENABLED flag
  2. 02Read idb context (enabled, ttl, customKey)
  3. 03Generate DJB2 hash key → set __idb_key

IDBPrecheckLink

  1. 01Call has(key, ttl) — check IndexedDB for valid entry
  2. 02Set __idb_cache_ready on context
cache_ready = true

IDBLink

get(key) from IndexedDB

__idb_from_cache: true

cache_ready = false

HTTP Link

Fetch from server

Write response → IndexedDB

Response → Component

IndexedDB Storage Schema

app-idb

IndexedDB Database

apollo-cache

Object Store

KEY

{operationName}#{hash}

DJB2 hash of query + variables

VAL

{ value: <response>, timestamp: <epoch ms> }

Full GraphQL response + write timestamp

IDX

"timestamp"

Enables TTL expiration checks

Example Entry

HomeSliderQuery#a1b2c3{ data: { slides: [...] }, timestamp: 1713600000000 }

TTL checked via timestamp comparison · Expired entries auto-deleted by has()

The Three Links Explained

1. IDBManageLink — The Gatekeeper

Initiates the process by verifying browser support for IndexedDB. Generates a unique cache key based on the GraphQL query, variables, and optional custom key. Sets internal context flags that downstream links use to make routing decisions.

2. IDBPrecheckLink — The Detective

Checks if a valid cache entry exists in IndexedDB. Calls has(key, ttl) to verify existence and freshness. Sets __idb_cache_ready flag so the next link knows which path to take.

3. IDBLink — The Speed Demon

A terminating link that serves responses directly from IndexedDB. Only reached when __idb_cache_ready is true. Bypasses network entirely for instant data retrieval.

Design Principles

Opt-in Caching. Developers must explicitly add { idb: { enabled: true } }to their query context. Prevents nasty surprises — real-time price data won't accidentally get cached for an hour.
Invisible to Components.From a React component's perspective, useQuery works exactly the same whether data comes from IndexedDB or the network. The magic happens at the transport layer.
Graceful Degradation. If anything goes wrong — IndexedDB unavailable, storage full — we silently fall back to the network. Caching should never break your app.
Background Writes.Writing to IndexedDB happens at “background” priority using the scheduler API. Cache updates won't jank your animations or delay user interactions.
SSR Coherency.We timestamp every cache entry and track the last SSR hydration. If a cache entry predates the most recent SSR render, we consider it stale. Prevents the awkward “flash of old content.”

Key Architectural Decisions

  • Why IndexedDB?It's async (unlike localStorage), has no 5MB limit, supports structured data, and doesn't block the main thread during writes.
  • Why three separate links? Separation of concerns made testing easier — each link has one job. IDBManageLink handles key generation, IDBPrecheckLink checks existence, IDBLink serves responses.
  • Why DJB2 hashing?It's fast, produces deterministic results, and base-36 encoding keeps keys short and readable.
  • Why scheduler.postTask?It gives us priority control that setTimeout can't. Background writes don't compete with user interactions.
  • Why a feature flag? Being able to disable IndexedDB caching instantly (without deploying code) saved us during an incident where we discovered a subtle bug in a specific browser version.
03

Implementation Deep Dive

The foundation of our caching layer is a set of low-level IndexedDB operations. These utilities handle database connection, data storage, retrieval, and expiration checking.

Configuration Constants

First, we define our database configuration and scheduler priorities:

// index-db/constants.ts
export const DB_NAME = 'app-idb';
export const STORE_NAME = 'apollo-cache';
export const DEFAULT_TTL = 2 * 60; // 2 minutes default

// Prioritize cache writes as background tasks
export const IDB_SET_POST_TASK = {
  priority: 'background',
};

// Prioritize cache reads as user-visible tasks
export const IDB_GET_POST_TASK = {
  priority: 'user-visible',
};

Database Operations

The core IndexedDB module provides connection pooling, TTL-based expiration, and SSR-aware cache invalidation through the markSSRHydration() function.

// index-db/index.ts
import { DB_NAME, DEFAULT_TTL, STORE_NAME, IDB_SET_POST_TASK, IDB_GET_POST_TASK } from './constants';

let storage: IDBDatabase | null = null;

interface IDBStoredValue<T> {
  value: T;
  timestamp: number;
}

// Track SSR hydration timestamp for cache invalidation
let lastSSRHydrationTimestamp = 0;

export function markSSRHydration(): void {
  lastSSRHydrationTimestamp = Date.now();
}

/**
 * Check if IndexedDB is supported and enabled
 */
export function isSupported(): boolean {
  if (typeof window === 'undefined') {
    return false;
  }
  return 'indexedDB' in window;
}

/**
 * Check if cached data has expired
 */
function checkExpired(createdAt: number, ttl: number) {
  const minuteToMs = ttl * 60 * 1000;
  const now = new Date().getTime();
  const expiredTime = createdAt + minuteToMs;

  // If SSR hydration happened after cache entry was written, treat as expired
  if (lastSSRHydrationTimestamp > 0 && createdAt < lastSSRHydrationTimestamp) {
    return true;
  }

  return expiredTime <= now;
}

/**
 * Connect to IndexedDB database
 */
function connect(): Promise<IDBDatabase> {
  if (!isSupported()) {
    return Promise.reject(new Error('[IDBLink] IndexedDB is not supported'));
  }

  if (storage) {
    return Promise.resolve(storage);
  }

  const open = indexedDB.open(DB_NAME);

  // Create object store on first run or version upgrade
  open.onupgradeneeded = () => {
    const store = open.result.createObjectStore(STORE_NAME);
    store.createIndex('timestamp', 'timestamp');
  };

  return new Promise((resolve, reject) => {
    open.onsuccess = () => {
      storage = open.result;
      resolve(open.result);
    };
    open.onerror = () => {
      console.error('[IDBLink] request error: ', open.error);
      reject(open.error);
    };
  });
}

/**
 * Store data in IndexedDB with background priority
 */
export async function set<T = Record<string, unknown>>(
  key: string,
  value: T
): Promise<void> {
  const task = async () => {
    const db = await connect();
    const now = new Date();
    const timestamp = now.getTime();

    const transaction = db.transaction(STORE_NAME, 'readwrite');
    const objectStore = transaction.objectStore(STORE_NAME);
    objectStore.put({ value, timestamp }, key);

    return new Promise<void>((resolve, reject) => {
      transaction.oncomplete = () => resolve();
      transaction.onerror = () => {
        console.error(`[IDBLink] failed to store value with key ${key}:`, transaction.error);
        reject(transaction.error);
      };
    });
  };

  // Use scheduler.postTask if available for background priority
  if ('scheduler' in window) {
    return (window as any).scheduler.postTask(task, IDB_SET_POST_TASK);
  }
  return task();
}

/**
 * Retrieve data from IndexedDB with user-visible priority
 */
export async function get<T = Record<string, unknown>>(
  key: string
): Promise<IDBStoredValue<T>> {
  const task = async () => {
    const db = await connect();
    const transaction = db.transaction(STORE_NAME, 'readonly');
    const objectStore = transaction.objectStore(STORE_NAME);
    const request = objectStore.get(key);

    return new Promise<IDBStoredValue<T>>((resolve, reject) => {
      transaction.oncomplete = () => resolve(request.result);
      transaction.onerror = () => reject(transaction.error);
    });
  };

  if ('scheduler' in window) {
    return (window as any).scheduler.postTask(task, IDB_GET_POST_TASK);
  }
  return task();
}

/**
 * Check if key exists and hasn't expired
 */
export async function has(key: string, ttl = DEFAULT_TTL): Promise<boolean> {
  const task = async () => {
    const db = await connect();
    const transaction = db.transaction(STORE_NAME, 'readwrite');
    const objectStore = transaction.objectStore(STORE_NAME);
    const request = objectStore.openCursor(key);

    return new Promise<boolean>((resolve, reject) => {
      request.onsuccess = (event: Event) => {
        const cursor = (event.target as IDBRequest).result as IDBCursorWithValue;
        const isExist = Boolean(cursor);

        if (!isExist) {
          return resolve(false);
        }

        const createdAt = cursor?.value?.timestamp ?? 0;
        const isExpired = checkExpired(createdAt, ttl);

        if (isExpired) {
          cursor.delete(); // Clean up expired entries
          resolve(false);
        } else {
          resolve(true);
        }
      };

      request.onerror = () => reject(request.error);
    });
  };

  if ('scheduler' in window) {
    return (window as any).scheduler.postTask(task);
  }
  return task();
}
04

The Results

The numbers tell a compelling story, but the real win was watching returning users experience near-instant page loads. We eliminated the “loading spinner fatigue” that plagued our homepage. The beauty of this solution is that it required zero changes to how developers write components — they just add a few lines of context and get persistent caching for free.

Performance Metrics

Redundant Homepage Network Requests

Before6–8 GraphQL queries per page load
After0–2 queries (cached operations served from IndexedDB)
Improvement~75% reduction in network requests for returning users

Perceived Data Load Time (slow 3G)

Before400–800ms per widget
After<50ms from IndexedDB
Improvement~10x faster for cached operations

SSR/CSR Cache Staleness Fix

BeforeStale data displayed until manual refresh
AfterAutomatic invalidation on hydration
Improvement~10 lines of code across 3 files, zero runtime cost

Main Thread Impact

BeforeN/A (no persistent cache)
After1 Date.now() call on hydration, 1 comparison per cache check
ImprovementEffectively zero — all heavy work at background priority

Origin Server Load

BeforeFull query volume on every page load
AfterOnly non-cached or expired queries reach origin
ImprovementEstimated 30–40% reduction in homepage GraphQL traffic

What We Actually Shipped

  • Returning users see homepage content instantly from IndexedDB, even on cold browser starts
  • Component developers opt-in with 3 lines of context — no architectural changes needed
  • Feature flag provides instant kill-switch without code deployment
  • Fail-open design means IndexedDB failures never degrade user experience below baseline
  • Per-query TTL granularity allows different cache windows for different data freshness needs

What's Next

  • LRU eviction policy: Cap the number of IndexedDB entries and evict least-recently-used when exceeded
  • Stale-while-revalidate: Serve from IndexedDB immediately, then background-refresh from network and update cache
  • Cross-tab synchronization: Use BroadcastChannel so when one tab refreshes data, it updates IndexedDB for all tabs
  • Automatic TTL tuning: Analyze cache hit rates per operation and adjust TTLs dynamically
Want to implement something similar? The key insight is that caching isn't just about speed — it's about respecting your users' time and bandwidth. Every millisecond you save is a millisecond they can spend actually using your product.

GraphQL IDBLink · 2023