skip to content

In Nuxt 4, why does a flight board's refresh button stop fetching after its `useFetch` gains a `getCachedData` returning `nuxtApp.payload.data[key]`, and how do you fix it?

level: seniorimportance: should knowfreq 27%

answer

  1. asked before every fetch
  2. the payload always has a copy
  3. ctx.cause names the trigger
  4. undefined means fetch

basics

~20 s

In Nuxt 4 getCachedData runs before every fetch, including refresh() and watcher runs, and any non-undefined return skips the request. The payload always holds the last result, so the refresh is served from cache. Return undefined when ctx.cause is refresh:manual or refresh:hook.

solid answer

~40 s

`getCachedData(key, nuxtApp, ctx)` runs before the handler; returning anything but `undefined` becomes `data` and skips the request. Nuxt 3 called it only on the initial fetch, so returning `nuxtApp.payload.data[key]` was a harmless hydration cache. Nuxt 4 calls it on every fetch through `experimental.granularCachedData`, on by default, and Nuxt writes each result into `payload.data[key]`, so the function always hits and `refresh()` never reaches the API. The fix is to branch on `ctx.cause`, which is `'initial'`, `'watch'`, `'refresh:manual'` for `refresh()`/`execute()`, or `'refresh:hook'` for `refreshNuxtData`: return `undefined` for the two refresh causes, and optionally apply a freshness window using a timestamp added in `transform`. Remember that `null` counts as a hit, and that entries with a custom `getCachedData` are no longer purged on unmount.

code

ts · 21 lines
ts
// app/composables/useDepartures.ts
type Departure = { id: string, number: string, state: string }

export function useDepartures () {
  return useFetch('/api/departures', {
    key: 'departures',
    transform: (list: Departure[]) => ({ list, fetchedAt: Date.now() }),
    getCachedData (key, nuxtApp, ctx) {
      // Explicit refreshes must always reach the API
      if (ctx.cause === 'refresh:manual' || ctx.cause === 'refresh:hook') {
        return undefined
      }
      const cached = nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
      // Reuse for 60 seconds, e.g. on back-navigation
      if (cached && Date.now() - cached.fetchedAt < 60_000) {
        return cached
      }
      return undefined
    },
  })
}

go deeper

for a junior

Recall that getCachedData returns cached data to skip a request, and undefined to let it run.

for a middle

Explain the four ctx.cause values and what fires each: the first fetch or a key change, a watcher, refresh(), and refreshNuxtData.

for a senior

Design a cache that never swallows explicit refreshes: branch on ctx.cause, stamp freshness in transform, pick dedupe per caller, and evict what is no longer purged.

for a principal

Decide whether page data needs a client cache at all, or whether server-side caching gives the freshness you need with less client code to maintain.

## What `getCachedData` does `getCachedData(key, nuxtApp, ctx)` is an option of `useFetch` and `useAsyncData` that runs **before** the handler. Return anything other than `undefined` and Nuxt uses it as `data`, sets `status` to `'success'` and skips the request. Return `undefined` and the request runs. `null` is a value: returning it is a cache **hit** that sets `data` to `null`. `ctx.cause` says why this fetch is happening: | `ctx.cause` | Fired by | |---|---| | `'initial'` | the first fetch, or a key change | | `'watch'` | a watched source or reactive fetch option changing | | `'refresh:manual'` | calling `refresh()` or `execute()` | | `'refresh:hook'` | `refreshNuxtData()` | ## Why the refresh button stopped working Nuxt 4 calls `getCachedData` on **every** fetch; the `experimental.granularCachedData` flag, on by default, controls this. Nuxt 3 called it only on the initial fetch, so many older snippets read: ```ts getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key] ``` After each successful fetch Nuxt writes the result to `nuxtApp.payload.data[key]`. From then on the function always returns something: 1. The refresh button calls `refresh()`. 2. Nuxt asks `getCachedData` with cause `'refresh:manual'`. 3. The function returns the payload copy. 4. Nuxt treats it as a hit and never calls the API. The 30-second poll freezes the same way, and so do filter changes when `useAsyncData` uses a static key plus `watch`. The departures board goes stale while every line of code looks correct. ## The fix: branch on the cause ```ts getCachedData (key, nuxtApp, ctx) { if (ctx.cause === 'refresh:manual' || ctx.cause === 'refresh:hook') { return undefined } return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key] } ``` To add freshness, stamp the data in `transform` (for example `fetchedAt: Date.now()`) and return the cached value only while it is younger than your limit. The board then gets instant back-navigation without serving old departures. ## What the default does The built-in implementation, per the 4.5.2 source: - while **hydrating**, it returns `nuxtApp.payload.data[key]`, the server's result, which is what prevents the double fetch; - for manual and hook refreshes, it returns nothing, so they always fetch; - otherwise it returns `nuxtApp.static.data[key]`, which is filled only when payload extraction serves prerendered data. The docs print a shorter version without the refresh check; the source has it. ## Side effects of a custom function - **No purge.** Entries with a custom `getCachedData` are not cleared when their last consumer unmounts. That is what makes back-navigation instant, and also what makes memory grow; evict with `clearNuxtData(key)` when the data stops being useful. - **Same function everywhere.** It is one of the options every same-key caller must share, or Nuxt warns in development. - **Escape hatch.** `experimental.granularCachedData: false` restores the Nuxt 3 timing; treat it as temporary. ## Polls, buttons and `dedupe` With a poll and a button both calling `refresh()`, `dedupe` decides what a second call does while the first is in flight. The default `'cancel'` aborts the first request and starts again; `'defer'` hands back the in-flight promise. Calling `refresh({ dedupe: 'defer' })` from the poll lets a tick join a user's click instead of cancelling it. ## Diagnosing a frozen board The symptom is quiet: no error, `status` reads `'success'`, and the network panel shows no request when the button is pressed. To confirm the cause: 1. Log `ctx.cause` inside `getCachedData`; a `'refresh:manual'` followed by a returned value is the smoking gun. 2. Check whether the function reads `nuxtApp.payload.data[key]` without looking at the cause. 3. Temporarily set `experimental.granularCachedData: false`; if refreshes start working, the cause check is what is missing. 4. Check for a `null` return meant as *no cache*; it counts as a hit. A second, related trap: a failed refresh resets `data` to the `default` factory's value, so a board with a custom cache can go from frozen to blank on the first network error. Decide explicitly what the user should see when a refresh fails. In an interview, the senior signal is naming the version change: the same line that was a harmless hydration cache in Nuxt 3 became a refresh blocker in Nuxt 4.

  • What does Nuxt's default `getCachedData` return?
    While hydrating, the server's result from `nuxtApp.payload.data[key]`, which is what prevents the double fetch. For manual and hook refreshes it returns nothing, so they always fetch. Otherwise it returns `nuxtApp.static.data[key]`, which is filled only when payload extraction serves prerendered data. The docs print a shorter version without the refresh check; the 4.5.2 source has it.
  • With a 30-second poll and a refresh button both calling `refresh()`, what does `dedupe` decide?
    What a call does while another is in flight. The default `'cancel'` aborts the in-flight request through its `AbortSignal` and starts a new one, and anyone awaiting the old promise gets the new result. `'defer'` returns the in-flight promise instead. Calling `refresh({ dedupe: 'defer' })` from the poll lets a tick join a user's click rather than cancel it.
  • One poll fails with a 503. What happens to the board's `data`?
    Nuxt sets `error` to a `NuxtError`, sets `status` to `'error'`, and resets `data` to the `default` factory's value, or `undefined` without one, so one failed poll can blank the board. If the last good list should stay visible, keep it in a separate ref updated on success, and render the error beside it.

saying these in an interview costs you the question

  • getCachedData runs only on the first load, so refreshes always reach the API.
  • Returning null from getCachedData forces a fresh request.
  • refresh() bypasses getCachedData by design in Nuxt 4.
  • dedupe: 'defer' queues a second request to run after the first one finishes.
  • Entries with a custom getCachedData are purged on unmount like any other.