August 7, 20266 min read

Fixing the iOS back-swipe image flicker in Next.js

Tweek-Eek, the headless Shopify store I keep writing about, has a collection page with a grid of product photography. On an iPhone, swiping back to that grid from a product made images that had already been on screen flash blank before reappearing. Desktop was fine. Only the back-swipe on iOS.

People have been reporting this for five years. The fix I landed on is about forty lines and, in the end, two HTML attributes. This is the write-up I went looking for and could not find.

A bug with a five-year paper trail

There is a Next.js discussion from March 2021 describing exactly this: next/image flickering when navigating back with a gesture. The author had already tried priority, unoptimized and loading="eager", and five years later the thread has zero replies. There is also an issue about UI flicker on the iOS swipe-back, reproduced on Next 13 and closed as “not planned”, which itself references an even older report of the same behaviour.

For the record, the stack I hit it on: Next.js 15.5.7 on the App Router, React 19.2, next/image with fill inside a <picture>, images optimised through /_next/image. Between the 2021 thread, the Next 13 issue and my own reproduction on 15.5.7, I believe every version to date is affected. I have not tested Next 16 yet.

What the back-swipe actually does

The gesture stacks two mechanisms. iOS Safari shows a snapshot of the previous page while the swipe is in progress, and behind that snapshot the router remounts the collection page. Remounting means every <img> is a brand-new DOM element — whatever was loaded before is irrelevant to the browser, because this element has loaded nothing.

New elements start out as loading="lazy", and a lazy image requests nothing until the browser has re-run its intersection checks. So the bytes sit ready in the HTTP cache while the element in front of you has not asked for them yet. Safari drops the snapshot, and for a few frames you are looking at empty boxes where product photos were a second ago.

One experiment pointed at the fix: hard-coding loading="eager" on the grid made the flicker vanish. An eager image requests its source the moment it mounts, the cache answers immediately, and there is nothing to wait for. The question was never whether eager works — it was how to get eager without paying eager’s price.

Making everything eager is the wrong kind of fix

The collection page renders 151 product images. I counted, repeatedly, for reasons that will become clear. Eager-loading all of them downloads the entire grid on a first visit, which is the exact thing lazy loading exists to prevent. The other classic move is sniffing /iPhone|iPad/ out of the user agent and special-casing iOS, which I would rather not have in the codebase.

The heavier options were not appealing either:

  • more aggressive Cache-Control headers on the image responses
  • a service worker caching images by hand
  • preloading the grid before navigating back
  • persisting the entire collection page state

All of that ignores the one observation that matters: the only images that flicker are images the visitor has already seen. So: lazy the first time a source is ever rendered, eager on every mount after it has loaded once, for as long as the SPA stays alive. A full reload resets everything back to normal.

Remember what has already loaded

A module-level Set has exactly the right lifetime: it survives client-side navigation and dies on a hard reload. Every image on the site already goes through one shared component, so the change slots in underneath without touching a single call site. The first version hung the bookkeeping on next/image’s onLoad:

TSX
const loadedSources = new Set<string>()

// inside the shared image component
const [wasLoaded] = useState(() => loadedSources.has(src))

return (
  <NextImage
    {...props}
    loading={wasLoaded ? 'eager' : 'lazy'}
    onLoad={() => loadedSources.add(src)}
  />
)

It worked, mostly. The first back-swipe still flickered; the second one did not. “Mostly” turned out to be two separate bugs.

The two reasons it only half worked

First: onLoad never fires for the images that matter here. next/image skips the callback when the browser finished the image before React attached to the element — which is precisely what a cached image does. I put a counter in the handler to be sure: Next’s internal loaded-marker was set on the element while my callback had run exactly zero times. The component could not see the very images it existed for.

So the tracking moved off onLoad and onto the element itself: a callback ref that checks img.complete at mount and otherwise attaches a one-shot native load listener.

Second: decoding. Next renders every image with decoding="async" — I counted 151 of those too — which explicitly permits the browser to paint the frame first and swap the decoded image in afterwards. An eager, cached image gets its bytes instantly, but the decode still happens off-frame: that is the one remaining flash on the first back-swipe. By the second swipe the decoded bitmap is still in memory and there is nothing left to swap. Hence “mostly”. For a source that has loaded before, decoding="sync" decodes it together with the frame.

TrackedNextImage.tsx
'use client'

import NextImage from 'next/image'
import { useCallback, useState, type ComponentPropsWithRef } from 'react'

// Sources that finished loading during this client session. Module scope
// survives client-side navigation and resets on a full page load.
const loadedSources = new Set<string>()

export default function TrackedNextImage({
  ref,
  ...props
}: ComponentPropsWithRef<typeof NextImage>) {
  const key = typeof props.src === 'string' ? props.src : null
  const [wasLoaded] = useState(() => key !== null && loadedSources.has(key))

  // Track on the element, not through next/image's onLoad: that callback
  // is skipped for images the browser finished before React attached —
  // exactly the cached case this component exists for.
  const trackRef = useCallback(
    (img: HTMLImageElement | null) => {
      if (typeof ref === 'function') ref(img)
      else if (ref) ref.current = img

      if (!img || key === null) return

      if (img.complete && img.naturalWidth > 0) {
        loadedSources.add(key)
        return
      }

      img.addEventListener(
        'load',
        () => {
          if (img.naturalWidth > 0) loadedSources.add(key)
        },
        { once: true },
      )
    },
    [key, ref],
  )

  return (
    <NextImage
      {...props}
      ref={trackRef}
      loading={props.priority || wasLoaded ? 'eager' : 'lazy'}
      // decoding="async" (Next's default) lets the browser paint the frame
      // before the image is decoded. For a source that already loaded once,
      // decode it together with the frame instead.
      decoding={props.decoding ?? (wasLoaded ? 'sync' : 'async')}
    />
  )
}

The shared image component now renders this wrapper instead of NextImage directly, and stopped passing loading itself — the wrapper owns that decision.

What this changes for SEO and performance: nothing

The set only fills through browser events, so the server-rendered HTML is untouched. I fetched the collection page and counted: 151 × loading="lazy", zero eager, identical before and after. A crawler starts a fresh page load with an empty set and sees the page it always saw, and hydration cannot mismatch for the same reason: on a fresh load the set is empty on both sides.

Fetch priority is untouched as well. In 15.5.7, fetchPriority is passed through from props and never derived from loading — I checked the source. Eager here means “do not wait for the intersection check”, not “jump the network queue”. And Core Web Vitals are measured on hard navigations, where nothing changed.

One warning before you go and test it: development keeps flashing a little, and that is the dev server’s doing, not the fix’s. next dev serves optimised images with Cache-Control: max-age=0, must-revalidate, so every image revalidates on every mount. Production serves the same images with max-age=31536000, and must-revalidate only applies once that year is over. Judge it on a production build.

Five years of open threads, and the repair comes down to two attributes on an <img>loading and decoding — applied only to sources the visitor has already seen, tracked on the element because the framework’s own callback would not tell me. The bytes were in the cache the whole time; the element just had to be told not to wait for them.