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.
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 -3Interpretation
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 4Interpretation
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/nullInterpretation
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.