PostgreSQL

Fix NeonDbError: Error connecting to database: fetch failed

Find out in one log line why Drizzle cannot reach Neon, why reads and writes fail together, and how to prove the connection works again.

Mahdi Ben RhoumaMahdi Ben Rhouma
9 min read

TL;DR#

NeonDbError: Error connecting to database: fetch failed means the Neon serverless driver sent your query as an HTTPS POST and got no HTTP response at all: the name did not resolve, the connection timed out, or the host in DATABASE_URL is wrong. Reads and writes go out as the same kind of request, so they fail the same way. The real reason is one level down, in err.sourceError.cause.code (EAI_AGAIN, ENOTFOUND, ETIMEDOUT and so on). Log it, fix the network or the host it points to, and confirm with a plain fetch to the endpoint: any HTTP status, even 400, proves the path works again.

The error from the question#

The Stack Overflow question comes from a Next.js 14 app with Drizzle and Neon. A Server Component on a dynamic [id] route fails while it loads data, although mutations against the same database had worked:

plaintext
⨯ NeonDbError: Error connecting to database: fetch failed
at async page (./src/app/tailor-cv/job/[id]/page.tsx:18:17)
digest: "4084647856"

The only answer, marked as a temporary fix, reports the same stack (Next 14, Postgres, Drizzle, Neon) failing on the poster's home Wi-Fi and working as soon as they switched to a neighbour's network. That fits the mechanism below, but the answer does not say what was wrong with the first network, and neither does this page.

On current versions the first line you see is different. drizzle-orm 0.44.0 introduced DrizzleQueryError, which wraps every driver error, and @neondatabase/serverless 1.0.0 and 1.2.0 print the error object rather than its message. Reproduced on drizzle-orm 0.45.4 and @neondatabase/serverless 1.2.0 (Node 22.23.3, node:22-alpine, 19 October 2026), with the container cut off from the network:

text
name: DrizzleQueryError
message: Failed query: select "id", "title" from "jobs"
cause.name: NeonDbError
cause.message: Error connecting to database: TypeError: fetch failed

Search for whichever of the two lines your logs contain; it is the same failure.

Why it happens#

drizzle-orm/neon-http runs on Neon's neon() function. The Neon serverless driver docs describe it plainly: it "sends each query as an HTTP fetch request" instead of opening a TCP connection to PostgreSQL. The Drizzle Neon page says the neon-http and neon-websockets drivers "use the neon-serverless driver under the hood".

The driver's source shows where the error comes from. In src/http/index.ts, the call to fetch sits in a try block, and anything it throws is wrapped:

ts
// excerpt; the request options are elided
let response;
try {
  response = await (fetchFunction ?? fetch)(url, { method: 'POST' /* ... */ });
} catch (err: any) {
  const connectErr = new NeonDbError(
    `Error connecting to database: ${err}`,
  );
  connectErr.sourceError = err;
  throw connectErr;
}

When Node's fetch cannot complete a request it throws TypeError: fetch failed, and the network error that caused it sits in that error's cause. So the text after the colon is always the same, and the useful part is two properties down: sourceError is the TypeError, and sourceError.cause is the DNS or socket error.

The URL is not your DATABASE_URL. The default fetchEndpoint in src/shims/net/index.ts replaces the first label of the host (your ep-… endpoint ID) with api. and returns 'https://' + newHost + '/sql'. A wrapped fetchFunction in the reproduction logged the request for an eu-west-2 connection string:

text
driver POST -> https://api.eu-west-2.aws.neon.tech/sql

Two things follow. First, the port in the connection string plays no part: the request is plain HTTPS to a shared regional host. A firewall or ISP that blocks only PostgreSQL's port, as the Stack Overflow answer suggests checking, would not cause this error on neon-http. Second, a select and an insert take exactly the same code path. Drizzle builds the SQL, the driver posts it, and nothing on the way knows whether the statement reads or writes. The reproduction ran an insert and then a select in the same process, and both failed with the same cause every time the network was down.

Fix#

1. Log the cause instead of the wrapper#

Catch the error once around the failing query and print the chain. This works whether Drizzle wraps the error (0.44.0 and later) or not:

ts
// lib/log-neon-error.ts
export function logNeonError(err: unknown) {
  const e = err as { cause?: any; sourceError?: any; message?: string };
  const neonErr = e?.sourceError ? e : e?.cause;
  const network = neonErr?.sourceError?.cause;
  console.error('query failed:', e?.message);
  console.error('driver said:', neonErr?.message);
  console.error('network cause:', network?.code ?? String(network));
}
tsx
// app/jobs/[id]/page.tsx
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { jobs } from '@/lib/schema';
import { logNeonError } from '@/lib/log-neon-error';
 
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  try {
    const rows = await db.select().from(jobs).where(eq(jobs.id, Number(id)));
    return <pre>{JSON.stringify(rows, null, 2)}</pre>;
  } catch (err) {
    logNeonError(err);
    throw err;
  }
}

Your server log then shows a code in place of the bare "fetch failed". The reproduction printed the same chain in plain Node, outside Next.js, with a small logging script (chain.mjs), and these are the causes it logged, each with nothing changed but the condition named:

text
sourceError.cause: Error: getaddrinfo EAI_AGAIN api.eu-west-2.aws.neon.tech
sourceError.cause: Error: getaddrinfo ENOTFOUND api.eu-west-2.aws.neon.tecc
sourceError.cause: AggregateError
sourceError.cause.code: ETIMEDOUT

The first came with no network at all, the second from a typo in the host (.tecc), the third from a run on this machine with the network up, where the connection timed out (ETIMEDOUT, inside an AggregateError). A rerun of the same command minutes later reached Neon. Why that one attempt timed out was not established, which is exactly why you log the code rather than guess.

2. Test the endpoint on its own#

Take Next.js and Drizzle out of the picture. This script derives the same URL the driver uses and posts to it once:

js
// reach.mjs — run with: node --env-file=.env reach.mjs
const url = new URL(process.env.DATABASE_URL);
const endpoint = 'https://' + url.hostname.replace(/^[^.]+\./, 'api.') + '/sql';
try {
  const res = await fetch(endpoint, { method: 'POST' });
  console.log(`${endpoint} answered HTTP ${res.status}`);
} catch (err) {
  console.log(`${endpoint} unreachable: ${err} / ${err.cause?.code ?? err.cause}`);
}

Run without a network and then with one, it printed:

text
https://api.eu-west-2.aws.neon.tech/sql unreachable: TypeError: fetch failed / EAI_AGAIN
https://api.eu-west-2.aws.neon.tech/sql answered HTTP 400

The 400 is the good result: any HTTP status means the path works. Its body, fetched once with curl, is a JSON error that starts with {"message":"invalid header: neon-connection-string", because the script sends no connection string. If this script fails on your machine, the problem is the network or the host in DATABASE_URL, not your Drizzle or Next.js code.

3. Fix what the code points to#

  • ENOTFOUND or EAI_AGAIN: DNS. Check the host for a typo first, then resolve it yourself. Neon's connection errors page suggests nslookup against your resolver and then against a public one; check the api. host the driver contacts as well as your ep-… host:
bash
nslookup api.eu-west-2.aws.neon.tech
nslookup api.eu-west-2.aws.neon.tech 8.8.8.8

If only the second command answers, your resolver is the problem. Neon lists "restrictive or misconfigured DNS resolvers (such as those provided by your ISP)" among the causes and suggests switching to a public resolver such as Google DNS or Cloudflare DNS. That is a plausible reading of the neighbour's Wi-Fi report, not a confirmed one.

  • ETIMEDOUT, ECONNREFUSED or ECONNRESET: the connection itself. The name resolved but nothing answered on HTTPS. Look at what sits between Node and the internet: a corporate proxy, a VPN, a firewall rule. Then rerun reach.mjs. When the failure comes and goes, check the Neon status page too.

  • Brief, one-off failures. Neon's driver page says connections can drop "during maintenance, updates, or network interruptions" and recommends retry logic for the HTTP driver. The same idea, written for a Drizzle query with the async-retry package:

ts
import retry from 'async-retry';
import { db } from '@/lib/db';
import { jobs } from '@/lib/schema';
 
export const getJobs = () =>
  retry(() => db.select().from(jobs), { retries: 5, factor: 2, minTimeout: 1000, randomize: true });

A retry hides a blip. It will not fix a resolver that never answers, so do it after step 1, not instead of it.

4. On a long-running server, consider TCP#

Neon's connection guide recommends "a standard TCP driver with connection pooling" for apps on a VPS, Docker or any host with persistent processes, and keeps the serverless driver for platforms such as Netlify Functions, Deno Deploy or Cloudflare Workers. Drizzle supports that with drizzle-orm/node-postgres and the pg package (documented, not reproduced here):

ts
// lib/db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
 
export const db = drizzle(process.env.DATABASE_URL!);

This changes the transport, so it changes what can block you: TCP to port 5432 instead of HTTPS. It is not a cure for a broken resolver, since pg has to resolve the same hostname. Note also that pg waits forever on a connection that never completes unless you set a timeout, as shown on the Sequelize authenticate() hang page. When drizzle-kit reports that it is using the pg driver and then stalls without an error, the drizzle-kit migrate hang page is the place to start.

Verify the fix#

The reproduction proved the cycle end to end against a local Neon proxy (local-neon-http-proxy, the image Neon's local development guide uses) in front of postgres:16-alpine. With the proxy stopped, the insert and the select both failed with fetch failed. Starting the proxy again and running the same command, with no change to the code, returned the row for both:

text
driver POST -> http://neon-proxy:4444/sql
insert OK: [{"id":1,"title":"Backend developer, London"}]
driver POST -> http://neon-proxy:4444/sql
select OK: [{"id":1,"title":"Backend developer, London"}]

In your own app the check is the same in two steps: reach.mjs prints an HTTP status instead of unreachable, and the page that threw now renders. If the logged cause code changes rather than disappearing, you have moved to a different problem; go back to step 3 with the new code.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

When reads fail and writes do not#

In @neondatabase/serverless and drizzle-orm/neon-http, the driver source has no separate path for reads: the transport is the same request either way. The reproduction ran on plain Node 22, outside Next.js, so it does not exercise the fetch that Next.js provides to Server Components. So when one query works and another fails, compare where and when each one ran. Two situations produce that pattern honestly:

  • Different moments on an unstable network. The reproduction's timed-out run and the successful rerun were minutes apart, with nothing else changed. A mutation sent during a good minute and a read during a bad one look like a reads bug.
  • Different machines. A page that Next.js prerenders during next build runs its query at build time, wherever the build runs. If that build environment cannot reach Neon (a locked-down CI runner, a build container behind a proxy), the read can fail while server actions on the deployed app work. The same build-time mechanism, with TypeError: fetch failed while a Compose service name fails to resolve inside docker build, is worked through on the Docker hostnames in Next.js production page. The question does not say whether its error came from a build or a running server, so find out which one logged it before you rule this case in or out.

The message on older driver versions#

The text after the colon depends on the driver version. Version 0.9.4, the latest release when the question was asked on 29 August 2024 (the npm registry dates it 26 June 2024, and 0.9.5 followed on 3 September 2024), builds the message from the error's message; the published code of 0.9.5 and 0.10.4 does the same, while 1.0.0 and 1.2.0 interpolate the error object. Run with no network, 0.9.4 printed:

text
name: NeonDbError
message: Error connecting to database: fetch failed
sourceError present: true
sourceError: TypeError: fetch failed
sourceError.cause: Error: getaddrinfo EAI_AGAIN api.eu-west-2.aws.neon.tech

So the sourceError property is there on the 2024 release too, and the logging helper in step 1 works on both lines.

The next error: password authentication failed#

Once the network path works, the next error is often about credentials. With the network up, the reproduction's made-up connection string got this back from Neon:

text
cause.message: password authentication failed for user 'neondb_owner'
sourceError present: false

That is progress: there is no sourceError, because Neon answered. Copy the connection string again from the Neon console, and read Neon's notes on password authentication failed for user. For the PostgreSQL side of the same message, such as a role without a password or a mistyped one, continue with Fix: password authentication failed for user "postgres".

The point to keep from all this is that "fetch failed" is not a diagnosis, only proof that no answer came back. The driver keeps the real reason in sourceError.cause, one log line gets it out, and a single fetch to api.<region>/sql tells you whether the fault is in your network or in your app.

Further reading: Fix Can't resolve 'fs' When Importing pg in Next.js.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote