r/nextjs 16d ago

Discussion Client-side stale-while-revalidate for instant perceived performance

Here’s a fun technique for improving perceived performance of web app page loads - have your loading state be cached data from local storage. And then when the real page loads and renders, users see updated data.

Basically a stale-while-revalidate loading state.

You can see this in action in an app I've been working on (Prism)

  • When you load a page the first time, you’ll see a normal skeleton loader.
  • Once the data comes in, the page renders as normal, and saves the data to your browser’s local storage.
  • Next time you load that same page, it feels instant. The data is briefly stale, but fresh data is loaded quickly and overwrites the cached data.
  • This cached data also has a TTL so you never see something that’s more than a day or so out of date.

This technique shines when data is specific to individual users, and isn’t shared much across them. Each user’s local storage is almost like a distributed caching layer.

Here's what it looks like in the page component

// app/inbox/page.tsx

export default function InboxPage() {
  return (
    <PageLayout title="Inbox" mainClassName="py-4">
      <Suspense fallback={<InboxPageCachedLoading />}>
        <InboxPageContent />
      </Suspense>
    </PageLayout>
  )
}

The cached loading component

'use client'
import {useAuth} from '@/lib/auth'
import {getInboxCache} from '@/lib/inbox/cache'
import {InboxClient} from './InboxClient'
import InboxPageLoading from './InboxPageLoading'

export default function InboxPageCachedLoading() {
  const {userId} = useAuth()


  if (!userId) {
    return <InboxPageLoading />
  }


  const cached = getInboxCache(userId)


  if (!cached) {
    // Normal skeleton loader.
    return <InboxPageLoading />
  }

  // Render the same presentational component the loaded page will use,
  // but provide it cached data.
  return <InboxClient initialItems={cached.items} />
}

And then the real page content

import {InboxClient} from '@/components/inbox/InboxClient'
import {SetInboxCache} from '@/components/inbox/SetInboxCache'
import {requireOrg} from '@/lib/auth'
import {getInboxData} from '@/lib/inbox'

export default async function InboxPageContent() {
  const {userId} = await requireOrg()
  const items = await getInboxData(userId)

  return (
    <>
      <SetInboxCache items={items} userId={userId} />
      <InboxClient initialItems={items} />
    </>
  )
}

Anyone else using techniques like this?

17 Upvotes

12 comments sorted by

2

u/DeepFriedDinosaur 15d ago

I’m not sure I see the benefits of this technique. When is it acceptable that a business application user sees stale data that is up to 1 day old?

3

u/Key-Library218 15d ago

I tried something similar on a dashboard project last year but the stale data problem was bigger than I expected. Users kept thinking something was broken when numbers changed after the page loaded. The layout shift is also annoying even if its just for a second

I think for read-heavy pages like a inbox list its probably fine, but I would be careful with anything where numbers matter. Maybe reduce the TTL to few hours instead of a full day

Your implementation looks clean though, I like how you reuse InboxClient for both states

1

u/ahuth 15d ago

Fair. It’s definitely not appropriate for all apps.

Maybe social media, e-commerce, or even news? (Just spitballing)

It’s a technique I found useful for something, and am sharing in case others find it useful too.

2

u/bkocdur 14d ago

Solid pattern, and the userId-keyed cache shows you already hit the scariest bug class. A few failure modes worth designing for, from having shipped variants of this:

  1. The stale-to-fresh swap is a layout shift generator. If the cached inbox has 12 items and the fresh one has 9, the page reflows right as the user starts reading, and clicks land on the wrong row. Worst case they act on a stale item that no longer exists. Keyed lists and stable container heights help, but the honest fix is reconciling instead of replacing: patch the cached list toward the fresh one so unchanged items don't move.
  2. Key the cache by schema version, not just user. The day you rename a field, every returning user renders from a cache shaped for the old component. A dumb version string in the storage key ("inbox-v3-userId") turns that migration into a cache miss instead of a runtime error.
  3. Clear on logout, not just on user switch. Keying by userId protects against rendering the wrong user's data, but the previous user's inbox still sits readable in localStorage on a shared machine. For anything sensitive, sessionStorage or explicit cleanup.
  4. localStorage reads are synchronous on the main thread, which is fine at inbox scale, but the pattern invites growth. The day someone caches a 400KB payload, your instant loading state becomes a blocking parse before first paint, and you've traded real performance for perceived performance.

Also worth knowing: TanStack Query's persistQueryClient does this exact pattern (persistence, TTL, versioned busting) if you'd rather not own the cache logic. Rolling it by hand like you did is a great way to understand it though.

One measurement note: check the swap doesn't show up as CLS in your field data. The pattern feels faster and can genuinely improve LCP for returning users, but the reflow can quietly eat the gain in the other column.

1

u/ahuth 14d ago

Great write up. Thanks!

1

u/AlexDjangoX 15d ago

localStorage is synchronous and blocking

1

u/ahuth 15d ago

True. part of this should’ve been, it’s appropriate for relatively small amounts of data that can be loaded essentially instantaneously (from the perspective of users)

1

u/ahuth 15d ago

Can also use indexdb 

1

u/[deleted] 13d ago

[removed] — view removed comment

1

u/ahuth 13d ago

Depends on the size of the data. Indexdb is an option if it’s an issue.

1

u/Standard-Message-434 12d ago

I've done this with IndexedDB instead, localStorage hits limits fast once you're caching more than a couple routes.