Back to Blog
PerformanceJune 30, 20264 min read

Browser performance: Fixing hydration latency with IndexedDB

Improve browser performance by offloading state to IndexedDB. Learn to mitigate hydration latency with a cache-aside synchronization pattern for faster UIs.

web performanceindexeddbstate managementfrontend engineeringhydrationPerformanceWeb VitalsFrontend

We’ve all seen it: a user lands on a complex dashboard, the skeleton screen flickers, and then the site hangs for a solid second while the app tries to re-hydrate its state from a massive JSON blob. It’s a classic case of Interaction to Next Paint: Architecting Deferred Hydration gone wrong, where the main thread is buried under the weight of parsing data and reconciling the DOM.

Last month, I spent about three days refactoring a client’s e-commerce platform because their "instant" state recovery was actually causing a 700ms block on initial page loads. We were dumping everything into localStorage, which is synchronous and blocking. Moving that logic to IndexedDB changed the game.

Why browser performance matters for state management

When you rely on localStorage for large state objects, you’re hitting a wall. Every read/write is synchronous. If your state blob is 500KB, that’s 500KB of blocking I/O that stops your JavaScript engine dead in its tracks.

To improve browser performance, we need to move toward asynchronous storage patterns. IndexedDB is the obvious choice, but it’s notorious for a clunky API. Instead of fighting it, we can treat it as a secondary cache, using a cache-aside pattern to keep the app responsive.

The cache-aside strategy

In this model, the application state lives in memory (like a React context or Redux store). When an update occurs, we update memory first, then fire an asynchronous write to IndexedDB. On the next load, we don't wait for the full hydration—we render a "shell" state immediately and patch it as the IndexedDB read resolves.

JAVASCRIPT
// A simplified cache-aside write for state management
async function persistState(key, value) {
  const db = await openDB(CE9178">'AppState', 1);
  await db.put(CE9178">'stateStore', value, key);
}

// Reading is async and non-blocking
async function loadState(key) {
  const db = await openDB(CE9178">'AppState', 1);
  return await db.get(CE9178">'stateStore', key);
}

Addressing hydration latency

The biggest trap in state management is the desire to have the "perfect" state before the first paint. This is exactly what leads to high hydration latency. If you wait for your store to populate from an API or a local database, you are effectively freezing the user out of their own UI.

Instead, prioritize the "Critical Path." If you're struggling with this, I recommend looking into Selective Hydration and Islands Architecture for Better TBT to see how you can break down these dependencies.

The synchronization flow

Here is how we orchestrate the hand-off between memory and disk to keep the UI fluid:

Flow diagram: Initial Load → IndexedDB Hit?; B -- Yes → Hydrate Shell State; B -- No → Fetch Fresh Data; Hydrate Shell State → Enable Interactivity; Fetch Fresh Data → Enable Interactivity; Enable Interactivity → Background Sync to DB

When to use IndexedDB vs. other storage

Not every state needs a database. If you’re just storing a user preference like "dark mode," localStorage is fine. But for complex application state, the performance gains of IndexedDB are undeniable.

FeaturelocalStorageIndexedDB
Capacity~5MBUnlimited (disk-based)
BlockingSynchronous (Blocks UI)Asynchronous (Non-blocking)
APISimple Key-ValueTransactional/Object Store
Best forPreferences/TokensComplex UI/App State

A note on consistency

One thing that still keeps me up at night is the potential for race conditions. If a user updates their state while a background sync is still pending, you can easily end up with a "stale write" that overwrites the newer data.

I’ve found that using a simple versioning timestamp on every state object is the best way to handle this. Before writing to the database, check the timestamp: if the in-memory version is older than what you’re about to overwrite, abort the write. It’s a small bit of extra boilerplate, but it prevents the kind of data corruption that makes users lose trust in your app.

If you are currently managing forms, be careful not to overcomplicate your synchronization logic. I’ve written before about React form handling: Controlled vs. Uncontrolled Components, and the same principle applies here: keep the source of truth simple and don't introduce unnecessary synchronization layers unless you truly have a performance bottleneck.

I’m still experimenting with how to integrate this with streaming SSR—there’s a lot of potential there to further reduce the time-to-interactive. For now, offloading the heavy state recovery to IndexedDB is the most reliable way I’ve found to keep the main thread clear and the UI snappy.

Similar Posts