How to Fix: Infinite loading with fetch using force-cache

5 min read

Why fetch(…, { cache: ‘force-cache’ }) can hang forever in Next.js dev mode

This bug looks like a network issue, but the real problem is a mismatch between Next.js request caching behavior and an unreachable upstream endpoint. In development, when a server component or route tries to fetch a resource with force-cache and the target server is not actually running, the request can appear to stay in an infinite loading state instead of failing in a way that is obvious from the UI.

Understanding the Root Cause

The issue happens when Next.js handles a server-side fetch using cache: ‘force-cache’ for a URL that does not respond successfully. In the reproduction, the app is started in dev mode, but nothing is listening on the target port. That means the fetch never gets a valid response from the upstream service.

Under normal Node.js behavior, an unreachable host should eventually throw a connection error such as ECONNREFUSED. However, in this scenario, Next.js wraps fetch to support its own data cache, request deduplication, and React Server Component rendering. When force-cache is involved, Next attempts to treat the request as cacheable application data. If the underlying fetch does not resolve into a usable response during render, the page can remain suspended, which looks like endless loading in the browser.

Technically, the important part is this:

  • force-cache tells Next.js to prefer cached data and treat the request as static/cacheable.
  • The target server is unavailable, so the fetch cannot produce a successful response.
  • Because the fetch occurs during server rendering, the route may stay in a pending state rather than rendering a clear error boundary.
  • In dev mode, this behavior is often more noticeable because the app is constantly recompiling and rendering with development tooling enabled.

This is why the page feels stuck instead of simply showing a fast error.

Step-by-Step Solution

The safest fix is to avoid using force-cache for data that depends on an external service that may be unavailable during development. You should also add explicit error handling and, if needed, a timeout.

1. Reproduce the problem correctly

If the fetch target points to a local service, confirm that the upstream server is actually running. For example, if your code requests http://localhost:3001, make sure something is listening on that port before starting the Next.js app.

Reference project: reproduction repository.

2. Remove force-cache for unstable local APIs

If the endpoint is dynamic, local-only, or not guaranteed to exist in development, switch to no-store.

async function getData() {
  const res = await fetch('http://localhost:3001/api/data', {
    cache: 'no-store'
  })

  if (!res.ok) {
    throw new Error(`Request failed: ${res.status}`)
  }

  return res.json()
}

This tells Next.js not to treat the request as reusable cached render data.

3. Add explicit error handling

Do not assume fetch will always return a valid response. Wrap it in try/catch so connection failures become visible.

async function getData() {
  try {
    const res = await fetch('http://localhost:3001/api/data', {
      cache: 'no-store'
    })

    if (!res.ok) {
      throw new Error(`HTTP ${res.status}`)
    }

    return await res.json()
  } catch (error) {
    console.error('Upstream fetch failed:', error)
    throw error
  }
}

4. Add a timeout using AbortController

If the upstream service is slow or dead, fail fast instead of waiting indefinitely.

async function getData() {
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), 5000)

  try {
    const res = await fetch('http://localhost:3001/api/data', {
      cache: 'no-store',
      signal: controller.signal
    })

    if (!res.ok) {
      throw new Error(`HTTP ${res.status}`)
    }

    return await res.json()
  } finally {
    clearTimeout(timeout)
  }
}

5. If you need caching, use it only for reliable endpoints

If the endpoint is stable and intended to be cached, you can still use Next.js caching features, but do it where failures are acceptable and observable.

async function getCachedData() {
  const res = await fetch('https://example.com/api/data', {
    cache: 'force-cache'
  })

  if (!res.ok) {
    throw new Error(`Request failed: ${res.status}`)
  }

  return res.json()
}

For local development dependencies, force-cache is usually the wrong choice.

6. Consider using next: { revalidate: … } instead

If your goal is incremental caching rather than permanent static-style caching, use revalidation.

async function getData() {
  const res = await fetch('http://localhost:3001/api/data', {
    next: { revalidate: 60 }
  })

  if (!res.ok) {
    throw new Error(`Request failed: ${res.status}`)
  }

  return res.json()
}

This often gives you a better balance between performance and safer runtime behavior.

Common Edge Cases

1. The port exists, but the route does not

If something is running on the target port but the path returns 404 or 500, the request will resolve, but your component may still fail if you do not check res.ok.

2. Mixed server and client fetch assumptions

Next.js caching rules differ between Server Components, Route Handlers, and client-side browser fetches. A fix that works in the browser may not behave the same during server render.

3. Dev mode hides production behavior differences

Development mode does not always match production caching semantics exactly. If you are testing cache-related behavior, verify with a production build as well.

4. DNS or container networking issues

In Docker, WSL, or remote dev environments, localhost may point to the wrong container or VM. If the service is actually elsewhere, use the correct hostname instead of assuming local loopback works.

5. Error boundaries are missing

If your app lacks a proper error.js boundary or route-level error handling, server fetch failures may feel like silent hangs. Adding a clear error UI makes diagnosis much easier.

// app/error.js
'use client'

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>Request failed</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

FAQ

Why does this happen only when I use force-cache?

Because force-cache changes how Next.js classifies and manages the fetch during rendering. It becomes part of the framework’s caching pipeline, which can expose pending render behavior more clearly when the upstream request never succeeds.

Should I always replace force-cache with no-store?

No. Use no-store for dynamic or unreliable sources, especially local development APIs. Keep force-cache for stable, cache-friendly data that is safe to reuse across renders.

What is the best production-safe fix?

The best fix is usually a combination of correct cache mode, explicit response checks, and timeouts. If you need caching, prefer revalidate for external APIs unless the data is truly static.

In short, the infinite loading is not caused by fetch alone. It is triggered by using Next.js cached server fetch against an endpoint that is unavailable. Start by removing force-cache, ensure the upstream service exists, and add fast-fail error handling so the page never gets stuck waiting for data that will never arrive.

Leave a Reply

Your email address will not be published. Required fields are marked *