← Debugging Guides

阅读中文版本

Node.js — getaddrinfo EAI_AGAIN Under Concurrency

Diagnose EAI_AGAIN DNS failures that appear only under load in Node.js, measure resolver concurrency limits, and cache or prefetch lookups with a resolver agent.

intermediatedebuggingnodejsdnsnetwork

Symptoms

  • Error: getaddrinfo EAI_AGAIN api.example.com appears intermittently once request concurrency rises
  • Failures cluster in bursts that coincide with traffic spikes, then vanish completely
  • getent hosts api.example.com on the same host succeeds during the failure window

Possible causes

  • The system resolver (musl in Alpine containers) serializes or times out lookups under heavy concurrency
  • libuv dispatches getaddrinfo to a small default threadpool that other fs and crypto work also competes for
  • The resolver in the container points to a nameserver with a rate limit or a short 5-second timeout
  • No DNS cache exists, so every outbound request performs a fresh lookup

Diagnosis steps

Compare resolver behavior under load

for i in $(seq 1 200); do getent hosts api.example.com & done | sort | uniq -c | sort -rn | head -3

Interpretation

If even the OS tool degrades under parallel lookups, the problem is the resolver path itself, not Node.

Check the threadpool contention

UV_THREADPOOL_SIZE=16 node loadtest.js # compare failure rate with default 4

Interpretation

A failure rate that drops when the threadpool grows confirms getaddrinfo is queuing behind other threadpool work.

Inspect the container resolver configuration

cat /etc/resolv.conf | grep -E "nameserver|timeout|attempts"; cat /etc/nsswitch.conf 2>/dev/null

Interpretation

A single nameserver with default 5-second timeout and limited attempts explains bursty failures under spikes; musl-based images add serialization.

Root cause

getaddrinfo runs on the libuv threadpool and the system resolver. Under concurrency, lookups queue behind a small pool of threads, and the resolver itself may rate-limit or time out. EAI_AGAIN is the temporary-failure signal: the lookup did not get a definitive answer within the window, and the request failed without a permanent DNS problem existing.

Fix

Cache resolved addresses at the application layer with a TTL, warm the cache on startup and ahead of bursts, raise UV_THREADPOOL_SIZE when fs and crypto compete for the same threads, and in containers prefer a local caching resolver or a glibc-based image when musl serialization is confirmed.

Risk note

Caching DNS indefinitely pins you to a stale endpoint after upstream failover. Respect the TTL and cap the cache entry lifetime.

Verify the fix

Rerun the spike workload and assert zero EAI_AGAIN, a flat lookup rate at the resolver (cache hits absorbing the spike), and correct failover when the target IP rotates within TTL bounds.

Symptom walkthrough

EAI_AGAIN means temporary failure, which is why it never appears one-off and always appears correlated with load:

Error: getaddrinfo EAI_AGAIN api.example.com
    at GetAddrInfoReqWrap.oncomplete (node:dns:109:18)

Establish which layer fails first. Parallel getent on the host measures the OS resolver; UV_THREADPOOL_SIZE experiments measure the libuv queue. When getent also degrades, the resolver configuration is the fault line. When only Node degrades, the threadpool is saturated by concurrent lookups plus other threadpool work like fs and crypto.

Fix in practice

The structural fix is not to resolve per request. Cache with TTL in front of every outbound client:

import { LookupAddress } from 'node:dns';

const cache = new Map(); // host -> { addrs, expiresAt }

async function cachedLookup(hostname, options, callback) {
  const hit = cache.get(hostname);
  if (hit && hit.expiresAt > Date.now()) {
    return callback(null, hit.addrs);
  }
  const addrs = await promisifiedLookup(hostname, { family: 4 });
  cache.set(hostname, { addrs, expiresAt: Date.now() + 30_000 });
  callback(null, addrs);
}

const agent = new http.Agent({ lookup: cachedLookup, keepAlive: true });

Raise the threadpool when the measurement pointed there: UV_THREADPOOL_SIZE=16 as the deployment default. In containers, verify /etc/resolv.conf timeouts, add attempts and a shorter timeout, and route through a caching stub resolver such as a local dnsmasq sidecar. On Alpine, confirm whether musl serialization matters for your profile, and consider a glibc image when it does.

Verification

Reproduce the spike: a load profile that previously produced EAI_AGAIN in bursts. After the fix, assert three things — zero EAI_AGAIN in the logs, the resolver-side query rate staying flat because cache hits absorb the spike, and a failover drill where the upstream IP rotates: the client must pick up the new address within the TTL window, proving the cache did not pin stale endpoints. Keep the cache-hit ratio exported as a metric to watch the resolver pressure trend.