# Fix “TypeError: fetch failed” on Vercel and Next.js

> Why Node.js throws TypeError: fetch failed on Vercel, how to read err.cause to find the real error, and how to fix localhost fetches, bad URLs and timeouts.

By Boris Tane, Founder of Polylane · Published October 18, 2026 · 11 min read
Canonical: https://polylane.com/learn/troubleshooting/vercel-typeerror-fetch-failed/

```text
TypeError: fetch failed
```

Node.js prints “TypeError: fetch failed” when its built-in fetch, which runs on undici, cannot finish an HTTP request: the connection was refused, the host could not be reached, a timeout fired or the socket closed. On Vercel the most common cause is a Server Component or page fetching your own API at http://localhost:3000 during next build, when nothing is listening. Read err.cause to see the real error, then call your data source directly or point the fetch at a URL that is reachable from where the code runs.

You see this in a Vercel build log under `Error occurred prerendering page`, or in runtime logs next to a 500 from a route that calls an API. It often works locally and breaks the first time you deploy. The message is the same whatever went wrong, so on its own it tells you very little.

What it does tell you: a `fetch()` call in your server code never got an HTTP response. The real reason is one level down, in the error's `cause` property. Most of the fix is reading that property.

## What “fetch failed” means in Node.js

The [Node.js globals docs](https://nodejs.org/api/globals.html#fetch) say the global `fetch` is based on undici, an HTTP/1.1 client written from scratch for Node.js. You can check which undici version your process bundles by reading `process.versions.undici`.

When undici fails below the HTTP layer, `fetch` rejects with a generic `TypeError` and attaches the low-level error as `cause`. The [undici errors reference](https://github.com/nodejs/undici/blob/main/docs/docs/api/Errors.md) says you can read timeout codes from `error.cause?.code` on wrapped fetch errors. It also tells you to match on `code` strings, because the global dispatcher may come from a different undici version than one you import, which makes `instanceof` checks unreliable.

HTTP error statuses such as 404 or 500 come back as a Response with a status code. MDN's [Response.ok page](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) says `res.ok` is true only for a status from 200 to 299, so check it for those. `fetch failed` is the case where no status arrived at all: the network step itself failed.

### Reading err.cause to find the real error

Log the cause before you change anything:

```ts
try {
  const res = await fetch(url);
} catch (err) {
  console.error("fetch failed", url, err.cause);
  throw err;
}
```

The cause usually falls into one of these groups:

```text
All of these arrive wrapped in TypeError: fetch failed

cause.code: 'ECONNREFUSED'             nothing listening at that host and port
cause.code: 'UND_ERR_CONNECT_TIMEOUT'  connection not set up before the connect timeout
cause.code: 'UND_ERR_HEADERS_TIMEOUT'  response headers did not arrive in time
cause.code: 'UND_ERR_BODY_TIMEOUT'     response body stalled between chunks
cause.code: 'UND_ERR_SOCKET'           the socket failed or closed mid-request
```

Then check where the error was printed. If it is in the build output, the fetch ran during `next build`. If it is in runtime logs, it ran inside a Vercel Function while serving a request. The fix depends on which one.

```mermaid alt="Deciding the cause of TypeError fetch failed on Vercel from where it appears and the code in err.cause"
graph TB
  A[TypeError: fetch failed] --> B[Log err.cause and the URL]
  B --> C{Build log or runtime log?}
  C -->|Build, ECONNREFUSED localhost| D[Call the data source directly]
  C -->|Runtime| E{What is cause.code?}
  E -->|ECONNREFUSED or DNS error| F[Fix the base URL per environment]
  E -->|UND_ERR_*_TIMEOUT| G[Set timeouts and handle them]
  E -->|UND_ERR_SOCKET| H[Retry idempotent requests]
  style A fill:#fee2e2,stroke:#fca5a5,color:#7f1d1d
  style D fill:#d1fae5,stroke:#6ee7b7,color:#065f46
  style F fill:#d1fae5,stroke:#6ee7b7,color:#065f46
  style G fill:#d1fae5,stroke:#6ee7b7,color:#065f46
  style H fill:#d1fae5,stroke:#6ee7b7,color:#065f46
```

## Cause 1: fetching your own API route on localhost during the build

**How to tell it's yours:** the build log shows `Error occurred prerendering page "/some-route"`, and the cause reads `connect ECONNREFUSED 127.0.0.1:3000`. The code does something like `fetch("http://localhost:3000/api/event")` inside a Server Component, a page or `generateStaticParams`.

This is the case people hit most. During `next build`, Next.js prerenders the pages it can render ahead of time. Your app is still being built, so it is not running yet, and unless something else serves port 3000 on that machine the connection is refused. The page throws, and the [prerender error](https://nextjs.org/docs/messages/prerender-error) fails the build. Locally it often works because `next dev` is already running on that port while you build.

Vercel's post on [common App Router mistakes](https://vercel.com/blog/common-mistakes-with-the-next-js-app-router-and-how-to-fix-them) opens with this exact pattern. Route Handlers and Server Components both run on the server, so the extra network hop is unneeded, and you can call the logic directly in the Server Component. Server-side fetches also need an absolute URL, which is why people write `localhost` in the first place.

**Fix:**

1. Move the logic out of the Route Handler into a plain function, and call that function from both the Route Handler and the Server Component:
   ```ts
   // lib/events.ts
   import { db } from "@/lib/db";

   export async function getEvents() {
     return db.event.findMany({ orderBy: { startsAt: "asc" } });
   }
   ```
   ```ts
   // app/match/page.tsx
   import { getEvents } from "@/lib/events";

   export default async function Page() {
     const events = await getEvents();
     return <EventList events={events} />;
   }
   ```
   ```ts
   // app/api/event/route.ts (keep it for browsers and other clients)
   import { getEvents } from "@/lib/events";

   export async function GET() {
     return Response.json(await getEvents());
   }
   ```
2. If the data comes from an external API, fetch that API's absolute URL straight from the component. Skip your own route.
3. Rebuild locally with nothing running on port 3000, so you test the same conditions as Vercel. Next.js has a flag for unminified stack traces with source maps:
   ```bash
   next build --debug-prerender
   ```
4. Push and check that the Vercel build log no longer shows the prerender error for that route.

Some answers suggest marking the page dynamic with `export const dynamic = 'force-dynamic'` so it renders per request and skips the build-time fetch. That moves the problem to runtime, where `localhost` is still wrong (see Cause 2). It also depends on your caching model: the [route segment config docs](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config) say Next.js 16 removed `dynamic`, `revalidate` and `fetchCache` when Cache Components is enabled. Calling the function directly works in both models.

## Cause 2: a base URL that does not resolve where the code runs

**How to tell it's yours:** the error shows up in runtime logs, and the cause is `ECONNREFUSED` against `127.0.0.1`, or a DNS lookup error against a host name. The URL you logged contains `localhost`, `undefined`, a staging host that no longer exists, or a host that differs between Preview and Production.

An `ECONNREFUSED` cause means nothing accepted the connection at that address and port. On Vercel your app is reached through its deployment domain, so a fetch to `localhost` from a function finds nothing to connect to. A base URL read from an environment variable breaks the same way when that variable is missing in one environment. Vercel scopes variables to Production, Preview or Development, and changes only apply to new deployments, so adding the value does nothing until you redeploy.

Vercel fills in [system environment variables](https://vercel.com/docs/environment-variables/system-environment-variables) that help here, once **Enable access to System Environment Variables** is ticked under **Environment Variables** in project settings:

- `VERCEL_ENV` is `production`, `preview` or `development`, at build and runtime.
- `VERCEL_URL` is the generated deployment domain. It has no `https://` prefix, and the docs say it cannot be used together with Standard Deployment Protection.
- `VERCEL_BRANCH_URL` is the generated Git branch domain, also without the protocol.
- `VERCEL_PROJECT_PRODUCTION_URL` is the shortest production custom domain, or the `vercel.app` domain if there is none. It is always set, even in Preview deployments.

**Fix:**

1. First ask whether the server needs to call itself at all. If the target is your own route, use the Cause 1 fix and call the function.
2. If you must call a separate service, build the URL in one helper so every environment gets a real host:
   ```ts
   // lib/base-url.ts
   export function apiBaseUrl(): string {
     if (process.env.API_URL) return process.env.API_URL;
     if (process.env.VERCEL_ENV === "production") {
       return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
     }
     if (process.env.VERCEL_BRANCH_URL) {
       return `https://${process.env.VERCEL_BRANCH_URL}`;
     }
     return "http://localhost:3000";
   }
   ```
   Add the `https://` yourself, because none of the Vercel URL variables include it.
3. Check that every variable the helper reads exists in each environment, then redeploy. The [vercel env docs](https://vercel.com/docs/cli/env) list `vercel env ls [environment]` and `vercel env add [name] [environment]`:
   ```bash
   vercel env ls production
   vercel env ls preview
   vercel env add API_URL production
   vercel --prod
   ```
4. Watch for one trap at build time. A build that fetches your production domain reaches whatever deployment serves that domain right now. A route you add in this commit is not live there yet, so the build can still fail or read stale data.

## Cause 3: the upstream is too slow and a timeout fires

**How to tell it's yours:** the cause code is `UND_ERR_CONNECT_TIMEOUT`, `UND_ERR_HEADERS_TIMEOUT` or `UND_ERR_BODY_TIMEOUT`. Failures come in bursts when a third-party API or your own backend slows down, and the same request works on retry.

The undici errors reference ties each code to a dispatcher option. `connect.timeout` covers opening the connection, `headersTimeout` covers waiting for response headers, and `bodyTimeout` covers the gap between body chunks.

**Fix:**

1. Set your own deadline per request with `AbortSignal.timeout()`, which the [Node.js docs](https://nodejs.org/api/globals.html#static-method-abortsignaltimeoutdelay) describe as a signal that aborts after the given number of milliseconds:
   ```ts
   const res = await fetch(`${apiBaseUrl()}/v1/quotes`, {
     signal: AbortSignal.timeout(5000),
   });
   ```
   MDN's [AbortSignal.timeout() page](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static) says the signal aborts with a `TimeoutError` DOMException, so checking `err.name === "TimeoutError"` tells your own deadline apart from undici's timeouts.
2. To change undici's timeouts for every fetch, install `undici` and set a global dispatcher. Node.js says this affects both undici and the built-in fetch:
   ```ts
   // a module every function imports
   import { Agent, setGlobalDispatcher } from "undici";

   setGlobalDispatcher(
     new Agent({
       connect: { timeout: 10_000 },
       headersTimeout: 15_000,
       bodyTimeout: 30_000,
     }),
   );
   ```
3. Keep your fetch deadline shorter than the function's maximum duration. If the function itself runs out of time, Vercel returns [FUNCTION_INVOCATION_TIMEOUT](https://vercel.com/docs/errors/FUNCTION_INVOCATION_TIMEOUT), a 504. See [how to debug Vercel function timeouts](/learn/troubleshooting/how-to-debug-vercel-function-timeouts/).
4. Handle the failure on purpose: return cached data, a fallback, or a clear error response. Left unhandled, it crashes the route.

## Cause 4: the socket closes partway through the request

**How to tell it's yours:** the cause code is `UND_ERR_SOCKET`, often with the text `other side closed`. It hits a small share of requests to one upstream and does not line up with a deploy.

Undici raises `SocketError` for a failure on the underlying socket. Its `socket` property holds the local and remote address and port, the remote family, and the bytes written and read. That tells you which upstream closed on you and how far the request got.

**Fix:**

1. Log the socket details with the cause:
   ```ts
   catch (err) {
     const cause = err.cause;
     console.error("fetch failed", {
       url,
       code: cause?.code,
       remote: cause?.socket?.remoteAddress,
       bytesRead: cause?.socket?.bytesRead,
     });
     throw err;
   }
   ```
2. Retry idempotent requests (GET, or PUT with an idempotency key) a small number of times with a growing delay:
   ```ts
   async function fetchWithRetry(url: string, init: RequestInit = {}, tries = 3) {
     for (let attempt = 1; ; attempt++) {
       try {
         return await fetch(url, { ...init, signal: AbortSignal.timeout(5000) });
       } catch (err: any) {
         const code = err?.cause?.code;
         const retriable = code === "UND_ERR_SOCKET" || code === "ECONNRESET";
         if (!retriable || attempt >= tries) throw err;
         await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
       }
     }
   }
   ```
3. Do not blindly retry POSTs that create records. A socket that closed after the server got the body can leave you with a duplicate.
4. If one upstream keeps closing connections, raise it with its owner, with the addresses and timestamps from step 1.

## When an unhandled fetch failure becomes a 500

A rejected `fetch` that nothing catches is an unhandled rejection. Inside a Vercel Function that crashes the invocation, and the request returns `FUNCTION_INVOCATION_FAILED`. If that is the code next to your 500, the fetch is the crash, and the steps in [the FUNCTION_INVOCATION_FAILED guide](/learn/troubleshooting/vercel-function-invocation-failed/) apply too.

To pull the failing requests from production with the [vercel logs](https://vercel.com/docs/cli/logs) command:

```bash
vercel logs requests --environment production --query 'httpStatus:500' --since 30m
```

Act fast on older failures: runtime logs are kept for 1 hour on Hobby, 1 day on Pro and 3 days on Enterprise.

## Keeping fetch failures visible and contained

- **Always log `err.cause`.** Route every server-side fetch through one wrapper that logs the URL, `cause.code` and the socket's remote address. The next failure then names its own cause.
- **Fail the build on localhost.** Add a CI check that fails if server code contains `localhost:3000`:
  ```bash
  ! grep -rn "localhost:3000" app lib --include=*.ts --include=*.tsx
  ```
- **Build with nothing on port 3000.** Run `next build` in CI on a clean machine, so a self-fetch fails there before Vercel sees it.
- **Give every outbound call a deadline.** Put `AbortSignal.timeout()` on each fetch, sized below the function's maximum duration.
- **Alert on 5xx by route.** A burst of `UND_ERR_*` causes usually means one upstream is struggling, and a per-route error alert shows you which route depends on it.

## Tracing fetch failures on Vercel with Polylane

Polylane detects production issues and fixes them by opening pull requests. It connects to Vercel, so when a fetch starts failing it works out why, and the fix arrives ready for your team's normal review.

Running on Vercel? See [how Polylane monitors Vercel in production](https://polylane.com/for/vercel/).

## Common questions

**Why does fetch work locally but fail with “fetch failed” on Vercel?**

Locally, next dev is usually running on port 3000, so a fetch to http://localhost:3000 during the build finds a server. On Vercel, the build runs before your app is serving anything, so nothing answers on that port and the connection is refused. Call your data function directly from the Server Component, or fetch an absolute URL that exists in that environment.

**How do I see the real error behind TypeError: fetch failed?**

Read the cause property: console.error(err.cause). Node's fetch runs on undici, which wraps the low-level error, and the undici docs say codes such as UND_ERR_CONNECT_TIMEOUT are found on error.cause?.code. Common values are ECONNREFUSED, UND_ERR_HEADERS_TIMEOUT, UND_ERR_BODY_TIMEOUT and UND_ERR_SOCKET.

**Should I use a relative URL like /api/event in a Server Component?**

No. Code running on the server with Node.js needs an absolute URL for fetch, as Vercel's App Router guidance points out. For your own routes, skip the HTTP call and import the function the route uses. Both run on the server, so the extra hop gains you nothing.

**Can I use VERCEL_URL as the base URL for server-side fetches?**

You can, with two caveats from Vercel's docs. The value has no https:// prefix, so add it yourself, and VERCEL_URL cannot be used together with Standard Deployment Protection. For links to production, VERCEL_PROJECT_PRODUCTION_URL is always set, even in Preview deployments.

**Does export const dynamic = 'force-dynamic' fix fetch failed?**

It stops the fetch from running during next build, so the build passes. But the page then fetches at request time, and the localhost URL is still the wrong address there (see Cause 2). In Next.js 16 with Cache Components enabled, the dynamic option has been removed, so calling your data function directly is the fix that works in both models.

**Does a 404 or 500 response from the API cause TypeError: fetch failed?**

No. A 404 or 500 comes back as a Response with that status, and MDN says res.ok is true only for statuses from 200 to 299. TypeError: fetch failed means no response arrived at all: the connection was refused, timed out, or the socket closed. Check res.ok separately for HTTP error statuses.

**How do I change fetch timeouts in Node.js?**

For one request, pass a signal from AbortSignal.timeout() with the number of milliseconds you are willing to wait. For every fetch in the process, install undici and call setGlobalDispatcher with an Agent that sets connect.timeout, headersTimeout and bodyTimeout. Node's docs say the global dispatcher applies to the built-in fetch as well.

**Why is my build failing with “Error occurred prerendering page” after fetch failed?**

Next.js prerenders pages during next build, and an error thrown while rendering one fails the build. When the thrown error is TypeError: fetch failed, the page fetched something that was unreachable at build time, most often your own app on localhost. Run next build --debug-prerender for unminified stack traces that point at the exact component.

## Sources

- [Global objects: fetch (Node.js Docs)](https://nodejs.org/api/globals.html#fetch)
- [Errors (undici Docs)](https://github.com/nodejs/undici/blob/main/docs/docs/api/Errors.md)
- [Response: ok property (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok)
- [AbortSignal: timeout() static method (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static)
- [Prerender Error with Next.js (Next.js Docs)](https://nextjs.org/docs/messages/prerender-error)
- [Route Segment Config (Next.js Docs)](https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config)
- [System environment variables (Vercel Docs)](https://vercel.com/docs/environment-variables/system-environment-variables)
- [Environment variables (Vercel Docs)](https://vercel.com/docs/environment-variables)
- [vercel env (Vercel Docs)](https://vercel.com/docs/cli/env)
- [vercel logs (Vercel Docs)](https://vercel.com/docs/cli/logs)
- [FUNCTION_INVOCATION_TIMEOUT (Vercel Docs)](https://vercel.com/docs/errors/FUNCTION_INVOCATION_TIMEOUT)
- [Common mistakes with the Next.js App Router and how to fix them (Vercel)](https://vercel.com/blog/common-mistakes-with-the-next-js-app-router-and-how-to-fix-them)
- [Polylane full content](https://polylane.com/llms-full.txt)
- [Polylane documentation](https://docs.polylane.com/llms-full.txt)

## About the author

Boris Tane is the founder of Polylane. He previously founded Baselime, observability for the future of the cloud, which Cloudflare acquired. At Cloudflare he built and led the Workers observability team.

## Related

- [Fix Vercel “FUNCTION_INVOCATION_FAILED” 500 errors](https://polylane.com/learn/troubleshooting/vercel-function-invocation-failed/): Why a Vercel Function returns 500 FUNCTION_INVOCATION_FAILED, how to find the crash in runtime logs, and how to fix throws, missing env vars, files and memory.
- [How to debug Vercel function timeouts](https://polylane.com/learn/troubleshooting/how-to-debug-vercel-function-timeouts/): Debug Vercel FUNCTION_INVOCATION_TIMEOUT 504s: find the slow call with vercel logs and httpstat, fix hangs, set maxDuration and verify on a preview.
- [Vercel 504 Gateway Timeout on Serverless Functions: Causes and Fixes](https://polylane.com/learn/troubleshooting/vercel-504-gateway-timeout-on-serverless-functions/): Fix Vercel 504 FUNCTION_INVOCATION_TIMEOUT errors: current duration limits, how to find the slow call, the fix for each cause and how to raise maxDuration.
- [Fix Vercel “MIDDLEWARE_INVOCATION_FAILED” errors](https://polylane.com/learn/troubleshooting/vercel-middleware-invocation-failed/): Why Vercel returns 500 MIDDLEWARE_INVOCATION_FAILED or EDGE_FUNCTION_INVOCATION_FAILED, how to find the failing middleware in logs, and how to fix it.
- [Fix Prisma “P1001: Can't reach database server”](https://polylane.com/learn/troubleshooting/prisma-p1001-cant-reach-database-server/): Why Prisma prints “P1001: Can't reach database server”, how to test the host and port it names, and how to fix private hosts, IPv6, paused databases and URLs.

Get started with one command: `curl -fsSL https://polylane.com/setup | bash` installs the CLI, connects your coding agents, and creates the account.
