Never show a loading spinner again
Loading states break flow. They remind users your app isn't ready yet.
Everyone hates spinners. Even the cute ones. I don't want to show loading. I want my app to feel ready. But work takes time. Database calls. AI streams. Compute. If we can't remove the time, let's remove the wait. Here's how I do it with a local cache in the browser using TanStack Query and IndexedDB.
Demo: ready.sf.engineering/
Repo: github.com/junnyyy/ready
The idea
- Keep your answers in the browser.
- Read from cache first. Render right away.
- Refresh in the background.
- Persist the cache in IndexedDB so reloads still feel instant.
- Writes and optimistic updates are a different beast, they require reconciliation logic not covered here.
Architecture at a glance
- Hot cache in memory. TanStack Query gives me sub-100 ms reads.
- Durable cache in IndexedDB. I use the persist client to save and restore.
- Background refresh on window focus and reconnect.
- Prefetch on intent. Hover and viewport warm the cache before a click.
Drop-in steps (using my repo code)
1. Add a persister for IndexedDB
TSX
import { get, set, del } from "idb-keyval";
import {
PersistedClient,
Persister,
} from "@tanstack/react-query-persist-client";
export function createIDBPersister(idbValidKey: IDBValidKey = "reactQuery") {
return {
persistClient: async (client: PersistedClient) => {
await set(idbValidKey, client);
},
restoreClient: async () => {
return await get<PersistedClient>(idbValidKey);
},
removeClient: async () => {
await del(idbValidKey);
},
} satisfies Persister;
}
2. Wrap your app with a persisted QueryClient
TSX
"use client";
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { useState } from "react";
import { createIDBPersister } from "@/lib/persister";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24,
},
},
}),
);
const [persister] = useState(() => createIDBPersister());
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
onSuccess={() => {
queryClient.resumePausedMutations();
}}
>
{children}
</PersistQueryClientProvider>
);
}
3. No spinner: just instant readiness
- Your existing queries render from the cache on load.
- React Query revalidates in the background.
- No spinner. The page feels ready.
Optional niceties for later
- Prefetch on hover or in-viewport to warm likely-next views.
- Add placeholderData:
(prev) => prevfor ultra-steady lists. - Keep the last good data on screen.
- Nudge changes with a soft highlight.
- Offer Undo and Retry for failed refreshes.
- Keep the cache bounded. Use
maxAge. Clear heavy keys on logout.
Accessibility
- Use ARIA live regions to announce updates.
- Avoid layout shift. If you must use skeletons, match the final size.
How I judge success
- Spinner count in core flows goes to zero.
- Time to content on navigation is 0 - 100 ms.
- Most reads come from cache on repeat views.
- Optimistic write errors stay under 1 percent.
Takeaways
- A persisted browser cache turns most loads into renders.
- Cache-first reads make the app feel instant.
- With TanStack Query and IndexedDB, this is practical and small.
See it live at ready.sf.engineering.
Reload. It doesn't blink.