Hostname → IP resolution that works around BEAM's broken DNS path on iOS and physical Android devices.
Why this exists
BEAM resolves hostnames by spawning an external helper called
inet_gethost (a port program). On macOS, Linux, Windows that
works fine. On mobile it doesn't, for two distinct reasons:
- iOS — the app sandbox forbids
execveof any binary the app didn't get a special pass for.inet_gethostnever runs. - Physical Android (some devices) —
inet_gethostdoes run (mob ships it aslibinet_gethost.so, allowed toexecveby theapk_data_fileSELinux label), but on some devices Bionic's getaddrinfo from the execve'd child process returns:nxdomainfor hostnames the same app's in-process HTTPS stack resolves fine. Confirmed on a Moto G Power 5G 2024 (Android 14): app-uid TCP-by-IP succeeds, but BEAM's:inet.getaddr/2fails with:nxdomain. A Moto G Power 2021 (Android 11) resolves fine through the same path, as does the Android emulator (its DNS proxy at10.0.2.3is reachable to anything) — so don't rely on it.
In either case :inet.getaddr/2 (and therefore Req, Finch, Mint,
ReqLLM, and basically every Elixir HTTP library) fails the moment
a request hits a hostname rather than a literal IP.
This module side-steps the problem by calling the OS resolver
(getaddrinfo) in-process via a NIF — same address space, same
uid as the rest of the app — then seeding :inet_db with the result
so subsequent BEAM-level lookups for the same host succeed from the
in-process file table.
How to use it
Robust everywhere, incl. cellular: resolve/1 · preresolve/1
resolve/1 calls the OS's getaddrinfo via a NIF — Darwin's on iOS,
Bionic's on Android — then seeds :inet_db's :file table, so
subsequent :inet.getaddr/2 lookups (Req / Finch / Mint) find the
result. Because it uses the OS resolver, it works wherever the OS
does — including iOS cellular (carrier's DNS) and physical
Android devices where the forked inet_gethost path is broken.
This is the recommended path on both platforms.
Preresolve your known hosts at startup — that's all most apps need:
def on_start do
Mob.DNS.preresolve(["api.example.com", "cdn.example.com"])
# …rest of startup…
endFor a host not known until request time, call resolve/1 just
before the request. Idempotent and cheap.
General fallback (WiFi-friendly): configure_pure_beam/1
Flips the lookup chain to [:file, :dns] and seeds nameservers so
any hostname resolves via raw DNS from inside BEAM — useful when
you can't enumerate hosts up front:
def on_start do
if :mob_nif.platform() == :ios, do: Mob.DNS.configure_pure_beam()
endTwo caveats that make this the fallback, not the default:
- Don't reset the chain to include
:nativeon iOS — exec'inginet_gethostthere is fatal, it crashes the BEAM. On physical Android:nativeis non-fatal but unreliable (returns:nxdomainfor hostnames that do resolve in-process);Mob.DNS.resolve/1is the recommended path there too. - It can't resolve on cellular by default. Its default
nameservers are public (Google / Cloudflare), which carriers
commonly block →
:nxdomain. iOS exposes no reliable API to read the carrier's resolvers, so there's nothing dependable to seed instead. On cellular, preferpreresolve/resolveabove, or pass:nameserversyou know are reachable.
The two compose: :file is consulted before :dns, so anything you
resolve/1 wins over the configure_pure_beam fallback.
Scope and limitations
- IPv4 only. Most cloud endpoints serve A records; IPv6 is a follow-up if it becomes useful.
- One IP per host. If the hostname has multiple A records, the first one is used. BEAM caches the result; failover isn't automatic. If your endpoint cycles IPs frequently you may need to re-resolve.
- No automatic refresh. Mappings stay in
:inet_dbuntil the BEAM exits or the host is resolved again. Callresolve/1after a network change (or when a backend's IP may have moved): the fresh answer replaces the host's previous address, and other hosts seeded throughresolve/1on a shared address keep theirs. - Don't hand-edit seeded hosts with
:inet_db.del_host(ip). It removes the address for every hostname seeded under it — with a DNS sinkhole answering0.0.0.0for many names, that unseeds all of them. Re-resolve instead. - Doesn't help raw NIF networking. If a third-party NIF calls
libc
getaddrinfoitself, it never goes through BEAM's DNS layer and doesn't need (or benefit from) this fix — it already works. Only:inet-mediated lookups (which covers almost all Elixir HTTP libraries) need our help. - Background-app network restrictions still apply. Android's App Standby / battery optimizer can block all outbound network from a backgrounded app, including TCP-by-IP — resolving a name won't help if the OS is silently dropping the connect(). Use a foreground service or keep the app foregrounded for sustained DNS / connectivity.
- Host dev (Mac, Linux) doesn't need this. The NIF isn't
loaded off-device; callers get
{:error, :nif_not_loaded}and should fall back to BEAM's normal path (which works on dev).
Errors
{:ok, {a, b, c, d}} # success
{:error, :badarg} # host arg invalid
{:error, :nxdomain} # no such hostname
{:error, :timeout} # resolver TRY_AGAIN
{:error, :no_address} # resolved but no IPv4
{:error, {:gai, code}} # raw getaddrinfo error code
{:error, :nif_not_loaded} # called off-device (host tests)
Summary
Types
The error shapes resolve/1 can return.
Hostname to resolve. Latin-1 only — we're not in a domain that uses IDN.
Functions
Configure BEAM's DNS path so :inet.getaddr/2 (and Req / Finch /
Mint / gen_tcp:connect/3 with a hostname) works without per-host
setup.
Resolve a list of hostnames. Returns a map of host → result so the caller can see which ones failed.
Resolve host to an IPv4 address and seed :inet_db so subsequent
:inet.getaddr/2 lookups (and thus Req / Finch / Mint) find it.
True when host is already seeded in :inet_db.
Types
Functions
@spec configure_pure_beam([{:nameservers, [:inet.ip_address()]}]) :: :ok
Configure BEAM's DNS path so :inet.getaddr/2 (and Req / Finch /
Mint / gen_tcp:connect/3 with a hostname) works without per-host
setup.
Sets the lookup chain to [:file, :dns] and seeds fallback
nameservers. Both ops are idempotent.
Why
BEAM's default :native lookup spawns inet_gethost, which iOS
refuses to execve. The :dns lookup, by contrast, performs raw
UDP/TCP DNS queries from inside BEAM via gen_udp / gen_tcp —
no port program, no fork. iOS doesn't block sockets, so the :dns
path Just Works.
After calling this, the whole :inet-mediated HTTP stack stops
needing a per-host resolve/1 call. :file stays first in the
chain so any host you do resolve/1 manually still wins — the two
paths compose.
When NOT to default to this
Reach for per-host resolve/1 (which uses libc getaddrinfo via
the NIF, going through Apple's resolver) when you need any of:
- VPN-pushed DNS for internal hostnames
.local/ mDNS service discovery- Search-domain expansion (single-label hostnames like
https://api/) - Captive-portal-aware lookup
- Encrypted-DNS-at-OS-level (DoH / DoT configured in iOS Settings)
These all require Apple's resolver, which only the NIF path
consults. The pure-BEAM :dns path queries whatever nameservers
you seed and nothing else.
Cellular caveat
This won't resolve on cellular with the defaults: the seeded public
resolvers (8.8.8.8 / 1.1.1.1) are commonly blocked by carriers →
:nxdomain. iOS exposes no reliable API to read the carrier's
resolvers, so there's nothing dependable to seed instead. For hosts
you can name, prefer preresolve/1 / resolve/1 (they use the OS
resolver and work on cellular); otherwise pass :nameservers you
know are reachable.
Opts
:nameservers— list of nameserver IP tuples (IPv4 or IPv6). Defaults to[{8, 8, 8, 8}, {1, 1, 1, 1}](Google + Cloudflare). Pass any list, including[]to skip seeding (e.g. if your app's:kernelenv already configures them). Common alternatives:[{9, 9, 9, 9}]— Quad9 (privacy-leaning, no logging)[{10, 0, 0, 1}, {10, 0, 0, 2}]— your corporate resolvers
Idempotent
Calling this twice is a no-op on the second call — duplicate nameservers aren't added, the lookup chain isn't reordered.
No resolv.conf (Android)
:inet_db watches /etc/resolv.conf and, every few seconds on the next
lookup, replaces its nameservers with the file's. Where the file doesn't
exist (Android, or any platform without one) it replaces them with none,
so seeded nameservers used to vanish within 5 s and every lookup after
that was :nxdomain. When the watched file is missing, this stops the
watch before seeding. The nameservers already configured are kept. A
resolv.conf that exists (iOS simulator, host) is still watched and
followed.
Examples
# Default — most apps need nothing more
Mob.DNS.configure_pure_beam()
# Override the fallback nameservers
Mob.DNS.configure_pure_beam(nameservers: [{9, 9, 9, 9}])
# Set the lookup chain but skip nameserver seeding
Mob.DNS.configure_pure_beam(nameservers: [])
@spec preresolve([host()]) :: %{ required(host()) => {:ok, :inet.ip4_address()} | {:error, error_reason()} }
Resolve a list of hostnames. Returns a map of host → result so the caller can see which ones failed.
Useful at app startup for the known-fixed set of backends your app talks to.
%{
"api.example.com" => {:ok, {93, 184, 216, 34}},
"auth.example.com" => {:error, :nxdomain}
}
@spec resolve(host()) :: {:ok, :inet.ip4_address()} | {:error, error_reason()}
Resolve host to an IPv4 address and seed :inet_db so subsequent
:inet.getaddr/2 lookups (and thus Req / Finch / Mint) find it.
The fresh answer replaces whatever IPv4 address host was seeded
with before, so calling it again after a network change (Wi-Fi →
cellular, a DNS sinkhole → a real resolver) refreshes the mapping.
Other hosts seeded through resolve/1 that share the old or new
address keep theirs; concurrent resolve/1 calls are serialised.
Writes your own code makes with :inet_db.add_host(ip, names) at the same
moment are not coordinated with it. On {:error, _} the existing
mapping, if any, is left in place.
See module doc for usage, scope, and error shapes.
True when host is already seeded in :inet_db.
Useful for short-circuiting in caller code that wants to avoid an
unnecessary NIF call — but resolve/1 is idempotent, so calling
it again is also fine.