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) => prev for 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.