Android VPN IPv6 Works but the Browser Still Picks IPv4
Trace the four stages where a private IPv6 address disappears: resolver, VpnService declaration, browser candidate check, and route delivery.
The VPN is up, the private web UI answers over IPv6 from a laptop, and the Android phone still opens the same page over IPv4 or refuses to load it at all. The AAAA record exists. The tunnel reports an IPv6 address. Nothing in the client looks wrong. Four separate decisions decide the outcome: which resolver answered, what the VpnService declared to Android, which address family the browser was willing to try, and whether a packet can make the round trip. Work them in that order instead of adding a default route and hoping the symptom moves.
Step 1: prove which resolver answered the AAAA query
Android does not have a single DNS path. A VPN can push a DNS server for its network, the system may use Private DNS for hostnames it qualifies, and Chrome can run its own encrypted resolver from chrome://settings/security. On a phone where the tunnel pushes 10.61.0.1 but Private DNS points at a public provider, the name inside your private zone is not being resolved by the machine that owns the zone. The browser gets an answer from a resolver with no authority for that name and treats it as missing, and the error text never mentions DNS.
Ask the tunnel's resolver directly before you change anything else. The first two commands show which DNS servers Android attached to the active network and whether Private DNS is in automatic or strict mode; the third runs from any client inside the tunnel, not from the phone.
adb shell dumpsys connectivity | grep -A3 "DnsAddresses"
adb shell settings get global private_dns_mode
nslookup -type=AAAA nas.lan 10.61.0.1
Step 2: read what the VpnService actually declared
The Android side is explicit rather than inferred. A VpnService.Builder adds addresses with addAddress, routes with addRoute, DNS servers with addDnsServer, and can restrict the whole tunnel to one address family with allowFamily. A builder that called allowFamily(AF_INET) and later added an IPv6 route has told the system two contradictory things, and the family restriction is what the system keeps. A route covering fd00:61::/64 combined with an interface address in a different prefix fails for a similar reason: the address exists, but it is not on-link for that route.
Read the declaration instead of trusting the toggle in the app. dumpsys netd prints the addresses and routes attached to the tun interface, and the per-network LinkProperties in dumpsys connectivity shows the DNS list next to the families the network claims.
adb shell ip -6 addr show tun0
adb shell ip -6 route show table all | grep tun0
adb shell dumpsys connectivity | grep -E "LinkAddresses|Routes"
If the tun interface holds an IPv6 address but no route matches the service prefix, IPv6 delivery is already broken before a browser is involved. That is a different bug from a browser that declines to try, and it needs a different fix.
Step 3: the browser decides whether to try IPv6 at all
Chromium does not use every AAAA record it receives. Its host resolver carries IPv6 reachability checks and candidate filtering that can drop or deprioritize IPv6 when the network is judged unable to carry it, and the resolver source shows that probe alongside the conditions around it. The logic is version- and flag-dependent, which is exactly why two phones on the same tunnel can behave differently. Once candidates exist, Happy Eyeballs races the families per connection, so an IPv6 path that answers slowly can still lose to IPv4 that answers quickly.
Watch the decision instead of inferring it. A session recorded through chrome://net-export, replayed in the netlog viewer, shows each resolution and the family chosen; chrome://net-internals/#dns shows the cached results. A resolution that returns only A records for a name that has AAAA at your own resolver points straight at this stage.
Step 4: verify a packet can make the round trip
Address selection is not delivery. Android keeps per-network routing tables and policy rules that pick a table by fwmark, so a route visible in the main table is not automatically the route a socket uses. Reading rules and asking the kernel to resolve a destination for a source address separates the two.
adb shell ip -6 rule show
adb shell ip -6 route get fd00:61::20 from fd00:61::10
The route get output is the one that answers the question. If it names tun0 and packets still die, move to the tunnel MTU and the gateway's IPv6 forwarding rather than back to the phone.
Why adding ::/0 usually makes it worse
Adding ::/0 changes the tunnel's claim from one prefix to every IPv6 destination. On the phone that can satisfy a reachability probe, because a test address outside your prefix now looks routable. It does not create IPv6 upstream capacity on the gateway, and plenty of home gateways have no working IPv6 uplink at all. The result is a black hole for public IPv6: names that used to fall back to IPv4 now hang, and the private page may look better only because the probe is measuring something different.
There is a policy cost too. With ::/0 in the tunnel, all IPv6 traffic leaves through the VPN, including traffic the network owner never agreed to carry. Treat that as a deployment decision with a named owner, not as a debugging step you take at 1 a.m.
The order of work that ends this
Confirm the AAAA answer comes from the resolver that holds the zone, read VpnService and netd state for the tun interface, capture a net-export trace to see whether Chromium dropped the candidate, then test the round trip with ip -6 route get. Fix the layer that actually failed and leave the split policy alone. If private names need different answers than public ones, that is what split tunneling is for.
Note the phone build, the browser version, and the resolver you queried. Those three facts turn "IPv6 is broken" into something the next person can reproduce.