skip to content

With Inertia's <Link prefetch>, a user deletes an invoice and then sees it again on a prefetched list page; why, and how do cacheFor and cache tags fix it?

level: seniorimportance: should knowfreq 28%

answer

  1. hover 75 ms, then fetch
  2. cached 30 seconds by default
  3. fresh cache: no request on click
  4. cacheTags plus invalidateCacheTags
  5. router.flush, flushAll, flushByCacheTags

basics

~20 s

Prefetched responses are cached, 30 seconds by default, and a click inside that window renders the cache without asking the server. Shorten cacheFor, or tag the link and invalidate the tag when the delete succeeds.

solid answer

~40 s

`<Link href="/invoices" prefetch>` fetches the page after a 75 ms hover, or on mousedown with `prefetch="click"`, or on mount, and stores the response in a client cache for `cacheFor`, 30 seconds by default. A click inside that window renders the cached page object without a server request. So a list prefetched before the user deleted an invoice elsewhere still contains it. Fixes, from narrow to broad: tag the prefetch with `cacheTags="invoices"` and pass `invalidateCacheTags: ['invoices']` on the delete visit, which flushes those entries when it succeeds; call `router.flushByCacheTags('invoices')`, `router.flush(url)` or `router.flushAll()` yourself; shorten `cacheFor` for volatile pages; or use a stale-while-revalidate pair such as `cacheFor={['10s', '1m']}` so stale data is shown briefly but refetched in the background.

code

jsx · 15 lines
jsx
import { Link, router } from '@inertiajs/react'

export function InvoicesNavLink() {
  return (
    <Link href="/invoices" prefetch cacheFor={['10s', '1m']} cacheTags="invoices">
      Invoices
    </Link>
  )
}

export function voidInvoice(id) {
  router.delete(`/invoices/${id}`, {
    invalidateCacheTags: ['invoices'],
  })
}

go deeper

for a junior

Recall that prefetch loads a page on hover and that the cached copy is reused for a short time.

for a middle

Explain the hover, click and mount modes, the 30-second default, and how cacheFor and cache tags control freshness.

for a senior

Prevent stale lists with tags and invalidateCacheTags, choose per-page cache policies, and guard GET handlers against prefetch side effects.

for a principal

Balance perceived speed against server load and staleness when setting product-wide prefetch defaults.

## How Inertia prefetching works Adding `prefetch` to a `<Link>` makes the client fetch the target page before the user commits to the click: | Mode | Trigger | |---|---| | `prefetch` or `prefetch="hover"` | the pointer rests on the link for 75 ms (`prefetch.hoverDelay`) | | `prefetch="click"` | mousedown, just before the click completes | | `prefetch="mount"` | as soon as the link renders | | `prefetch={['mount', 'hover']}` | a combination | The request is a normal Inertia visit with an extra `Purpose: prefetch` header, which Laravel exposes as `$request->prefetch()`. The response is stored in a client-side cache keyed by the URL and visit options. ## Why stale data appears The cache lifetime is `cacheFor`, **30 seconds** by default, set globally with `prefetch.cacheFor` or per link. If the user clicks within that window, the client renders the cached page object **without contacting the server**. Any change made in between (an invoice deleted from a detail page, a status changed by a colleague) is not in the cached copy. A `prefetch="click"` link without its own `cacheFor` keeps the response only for the visit it prepares. The client does flush the cache for a URL once it receives a fresh, non-prefetched response for it, but nothing is flushed on a mutation elsewhere unless you ask. ## Controls - **`cacheFor`**: a duration such as `'10s'`, `'1m'` or a number of milliseconds. Shorter values fit volatile lists. - **Stale-while-revalidate**: `cacheFor={['10s', '1m']}`. Within the first value the cache is served without a request; between the two, the stale copy is shown and a background request refreshes it; after the second, a normal request is made. - **`cacheTags`**: label prefetched responses, e.g. `cacheTags="invoices"` or an array. - **`invalidateCacheTags`**: a visit option on `router.post/put/patch/delete`, `useForm` submissions and the `<Form>` component; the tagged entries are flushed when the visit succeeds. - **Manual flushing**: `router.flushByCacheTags('invoices')`, `router.flush('/invoices')`, `router.flushAll()`, or the `flush` function from `usePrefetch()` for the current page. ## Fixing the invoices example 1. Tag the list link: `<Link href="/invoices" prefetch cacheTags="invoices">`. 2. Invalidate on mutation: `router.delete(url, { invalidateCacheTags: ['invoices'] })`. 3. For pages many users edit, shorten `cacheFor` or use the stale-while-revalidate pair. 4. If every navigation should start clean, `router.on('navigate', () => router.flushAll())` is the blunt option. ## Costs on the server - **Speculative load**: hover prefetch fires for links the user never clicks; `mount` prefetch fires for every rendered link. Budget for it on expensive pages. - **Side effects**: prefetch only makes sense for idempotent `GET` pages. Never let a `GET` handler change state, because it may now run without a click. - **Session bookkeeping**: the adapter already excludes prefetch requests from its optional previous-URL tracking; custom middleware that records "last visited" should check `$request->prefetch()` too. ## Debugging a stale page 1. Open the network tab and hover the link: a request with `Purpose: prefetch` confirms the prefetch. 2. Click within the cache window: no new request means the page came from cache. 3. Check the link's `cacheFor` and the global `prefetch.cacheFor` default. 4. Check whether the mutation that should invalidate the page passes `invalidateCacheTags`, and whether the link carries a matching `cacheTags` value. 5. As a quick experiment, call `router.flushAll()` after the mutation; if the problem disappears, the fix is a tag or a shorter lifetime. ## Choosing defaults | Page type | Suggested setting | |---|---| | Static or rarely changing (settings, help) | `prefetch` with a longer `cacheFor` | | Lists users mutate (invoices, tickets) | `prefetch` with tags and `invalidateCacheTags` on mutations | | Real-time views (queues, live status) | no prefetch; use polling or a short-lived `click` prefetch | | Expensive reports | `prefetch="click"` so only a committed click pays |

  • How can the Laravel side tell a prefetch from a real visit?
    Inertia sends a `Purpose: prefetch` header on prefetch requests, and Laravel's `$request->prefetch()` returns true for it. Use it to skip side effects such as recording a last-visited page or incrementing view counters. The Inertia middleware already skips prefetches when it stores the previous URL.
  • What does the stale-while-revalidate form of cacheFor change?
    With `cacheFor={['10s', '1m']}` a click within 10 seconds is served from cache with no request. Between 10 seconds and a minute the stale copy renders at once and a background request fetches fresh data, which then replaces it on the page. After a minute the cache is expired and the visit is a normal request.

saying these in an interview costs you the question

  • Prefetched pages are always revalidated on click.
  • Prefetch only starts when the link is clicked.
  • Inertia flushes the prefetch cache after any mutation automatically.
  • cacheFor is measured in minutes when given a number.
  • Prefetching a GET route that changes state is harmless.