Offline-First with Solid-Query and IndexedDB
Close Ditto. Reopen it. Your conversations are there instantly, before any network requests complete.
That’s the goal, anyway. Getting there required wiring up IndexedDB persistence for our Solid-Query cache—and debugging some surprisingly gnarly issues along the way.
The Setup
TanStack Query (Solid-Query in our case) has first-class persistence support. The basic idea: serialize your query cache to storage, restore it on app load. Users see their data immediately while fresh data fetches in the background.
We used @tanstack/solid-query-persist-client for the integration:
// App.tsx
import { PersistQueryClientProvider } from "@tanstack/solid-query-persist-client";
import { persistOptions, queryClient } from "./lib/queryClient";
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={persistOptions}
>
{/* Your app */}
</PersistQueryClientProvider>
);
}
The PersistQueryClientProvider wraps the standard QueryClientProvider and handles the restore/persist lifecycle automatically.
The QueryClient Configuration
Here’s what our queryClient setup looks like:
// lib/queryClient.ts
import { QueryClient } from "@tanstack/solid-query";
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
const CACHE_LIFETIME_MS = 60 * 60 * 1000; // 1 hour
const CACHE_BUSTER = "v1";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: CACHE_LIFETIME_MS,
},
},
});
export const persister = createAsyncStoragePersister({
storage: indexedDBStorage,
key: "ditto-solid-query",
});
export const persistOptions = {
persister,
maxAge: CACHE_LIFETIME_MS,
buster: CACHE_BUSTER,
};
A few things worth noting:
- 1-hour TTL: Cached data expires after an hour. Long enough for offline use, short enough that stale data doesn’t become a problem.
- Cache buster: Increment
CACHE_BUSTERwhen you need to invalidate everyone’s cache on deploy. - Built-in throttling: The async storage persister has built-in 1-second throttling by default. IndexedDB writes aren’t free.
- Only success states: Failed or pending queries don’t get persisted.
The First Approach: idb-keyval
Our initial implementation used idb-keyval, a popular IndexedDB wrapper:
import { get, set, del } from "idb-keyval";
const indexedDBStorage = {
getItem: (key) => get(key),
setItem: (key, value) => set(key, value),
removeItem: (key) => del(key),
};
Simple. Clean. Broken.
The Problem: Shared Database Conflicts
idb-keyval uses a default database called keyval-store. Harmless, unless another library or browser extension also uses idb-keyval with the defaults.
We started seeing errors in production:
Internal error opening backing store for indexedDB.open
Failed to persist query client to IndexedDB
Not occasionally. Hundreds of them. Console spam that made debugging anything else impossible.
The root cause: database corruption from conflicting writes. Some ad blocker or extension was using the same keyval-store database, and their writes were colliding with ours.
The Fix: Native IndexedDB with Isolated Database
We replaced idb-keyval with a native IndexedDB implementation:
const DB_NAME = "ditto-query-cache";
const STORE_NAME = "query-cache";
const DB_VERSION = 1;
let dbUnavailable = false;
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
});
}
const indexedDBStorage = {
async getItem(key: string): Promise<string | null> {
if (dbUnavailable) return null;
const db = await openDB();
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const request = store.get(key);
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => resolve(null);
});
},
async setItem(key: string, value: string): Promise<void> {
if (dbUnavailable) return;
const db = await openDB();
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
store.put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
},
async removeItem(key: string): Promise<void> {
if (dbUnavailable) return;
const db = await openDB();
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
store.delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
},
};
Key changes:
- Isolated database:
ditto-query-cacheinstead of sharedkeyval-store - Graceful error handling: Errors resolve instead of reject—the app works even if persistence fails
- Availability flag: More on this next
The Second Problem: Ad Blockers
Turns out, some privacy-focused ad blockers block IndexedDB entirely. They see it as a tracking vector (and they’re not wrong).
When IndexedDB is blocked, you get this:
Internal error opening backing store for indexedDB.open
Every. Single. Write. Attempt.
Our solution: check IndexedDB availability on first access and set a flag to skip all subsequent operations if it’s unavailable.
let dbUnavailable = false;
let dbChecked = false;
function checkIndexedDBAvailable() {
if (dbChecked) return !dbUnavailable;
dbChecked = true;
if (typeof indexedDB === "undefined") {
dbUnavailable = true;
console.warn("IndexedDB not available. Query cache persistence disabled.");
return false;
}
return true;
}
// Then in each storage method:
async function getItem(key: string): Promise<string | null> {
if (dbUnavailable) return null;
// ... actual IndexedDB operations with try/catch
}
If the first real database open fails (blocked by ad blocker), we set dbUnavailable = true and all subsequent operations become no-ops. One warning message instead of hundreds of errors. The app works fine without persistence—users just don’t get the instant-load experience.
Why Not localStorage Fallback?
We considered falling back to localStorage when IndexedDB is unavailable. Decided against it:
- Size limits: localStorage caps at 5-10MB. Our query cache can exceed that.
- Sync API: localStorage is synchronous, which blocks the main thread.
- Complexity: More code paths mean more bugs.
- Diminishing returns: If someone has blocked IndexedDB for privacy, they probably don’t want localStorage persistence either.
Graceful degradation to no-op is simpler and respects user intent.
Verifying It Works
Chrome DevTools makes this easy:
- Open DevTools → Application tab
- Expand Storage → IndexedDB
- Find
ditto-query-cachedatabase - Click
query-cacheobject store - Look for your cache key (
ditto-solid-query)
To test persistence:
- Load the app, navigate around to populate queries
- Check IndexedDB—data should appear
- Close browser completely
- Reopen the app
- Data should render immediately, before network requests complete
To force a fresh start: right-click the database → Delete database.
The User Experience Win
With persistence in place:
- Instant data on launch: Cached conversations appear immediately
- Offline support: App loads cached data even without connectivity
- Reduced API calls: Background refetch only when data is stale
The user experience difference is noticeable. Open the app, your conversations are there. No spinner, no skeleton loaders, no waiting for the network.
Gotchas Summary
-
Don’t use shared IndexedDB databases:
idb-keyvaldefaults can conflict with other code using the same library. -
Handle IndexedDB unavailability gracefully: Ad blockers and privacy settings can block it entirely.
-
Test with blocked IndexedDB: Install an ad blocker, enable strict tracking protection, verify your app still works.
-
Consider cache size: Set appropriate TTLs to prevent unbounded growth.
-
Plan for cache invalidation: The
busteroption lets you force-clear caches on deploy when your data schema changes.
The Stack
@tanstack/solid-query- Data fetching with caching@tanstack/solid-query-persist-client- Persistence provider for Solid@tanstack/query-async-storage-persister- Official persister with throttling- Native IndexedDB - Storage backend (no wrapper libraries)
Offline-first isn’t just for mobile apps with spotty connections. It’s about respecting your users’ time. Nobody should wait for a network request to see data they looked at 30 seconds ago.
We’ve documented our Solid-Query patterns (including persistence) as a reusable skill:
npx skills add https://github.com/omniaura/skills --skill solidjs-patterns
— Peyton