You allowlisted a domain, the query reaches your app, the matcher returns allowed, and the page still never loads. At that point most people start rewriting the matcher and adding wildcard comparisons. In my experience the matcher is usually correct, and the failure sits one layer later: in the DNS answer you synthesized, the route you installed, or the socket you used to forward the query. The list is the easy part; the packet path is where allowlists break.

The matcher is rarely the bug

The first thing to separate is blocked from broken. A blocked name should fail fast and visibly, because the browser gets an answer saying the name does not exist and stops. A broken name hangs, retries over IPv6, falls back to another transport, and leaves you with a spinner. If you see a spinner, your allowlist is probably answering something the client cannot act on.

An empty A record is the classic mistake. Returning NOERROR with zero answers looks like a valid reply, so the stub resolver accepts it, the browser tries again, tries AAAA, tries another resolver, and gives up much later. A response code of 3, NXDOMAIN, with the question section echoed and the transaction ID copied, ends the conversation immediately. Use it for every name you deliberately refuse.

IPv6 deserves the same decision rather than a default. If you answer NXDOMAIN for A and let AAAA through, the client can reach the blocked name over IPv6 and the filter leaks. Some implementations answer blocked AAAA queries with a loopback address so the connection fails at once instead of falling back. Pick one policy, apply it to both record types, and log both.

A route that captures everything owns everything

A DNS-only design never adds addRoute("0.0.0.0", 0), so only queries enter the TUN interface and everything else keeps using the underlying network. That is the safe shape, and it also explains why some allowlists appear to work on Wi-Fi and fail on cellular.

The moment a default route exists, every flow is yours. A TCP packet that arrives at the interface and is neither forwarded nor answered sits in the queue until the client times out, and there is no error to log because nothing errored. I watched someone spend an afternoon on a broken allowlist that was really a missing forwarder for port 443. Log the protocol and destination of every packet you decide to drop.

The resolver socket has to leave the tunnel

Forwarding is where the next trap sits. Carrier resolvers often live on a private address the tunnel cannot reach, so the query you forward goes nowhere and the app reports a read timeout. Send approved queries to a resolver you know is reachable, and pass that socket to VpnService.protect() first. An unprotected socket sends the query back into your own tunnel, where it arrives at the app again and loops until the timeout fires.

The failure looks identical to a network outage, which is why it survives so long in bug reports. Keep counters for queries sent and replies received: if the first number climbs and the second stays at zero, you are looking at the socket, not the list.

Size and checksums fail without a message

Silent drops also come from size. A DNS reply with a large answer set, or a TCP handshake with generous options, can exceed a path MTU you never set explicitly; the fragment disappears upstream and the client either retries smaller or stops retrying. Setting the interface MTU to 1280 is a conservative choice that survives most cellular paths, and being explicit beats inheriting a default nobody measured.

If you build packets yourself, the checksum is the other silent killer. A UDP checksum computed over a pseudo-header that leaves out the UDP length or the protocol field produces a packet that looks well formed on your side and is discarded by the receiver without a trace. Compare your pseudo-header against the specification field by field, and print the computed checksum for one known flow while you debug.

What a VpnService will not do for you

Two limits are worth knowing before you design around them. No ordinary app can install a system-wide proxy or PAC file outside Wi-Fi, and that part has not changed. The per-app path has: before API 29 a VpnService.Builder could not carry a proxy at all, and from API 29 setHttpProxy(ProxyInfo.buildPacProxy(uri)) hands the platform a PAC script to evaluate for the VPN network. Evaluate rules in your own code when you target older APIs or when the PAC model cannot express the rule. UDP is not free either: browsers send only TCP through a SOCKS proxy, and most SOCKS5 clients never implement UDP ASSOCIATE, so a proxy-based design drops or leaks UDP while TCP looks healthy.

That second limit is the reason a UDP-based protocol can work on a plain network and fail the moment your VPN connects. If the tunnel is expected to carry UDP, the forwarder has to implement the forwarding path for it, not just the TCP path with a different socket type.

Check in this order

Work the list in order and stop at the first stage that fails, because every later check is noise until the earlier one holds.

  1. List the installed routes and confirm whether non-DNS packets enter the TUN interface at all.
  2. Log the query name, the decision and the response code for one blocked and one approved name.
  3. Confirm the resolver socket is protected and the chosen resolver answers from inside the tunnel.
  4. Confirm the interface MTU and the checksum of one reply you built by hand.
  5. Repeat the same page on Wi-Fi and cellular, since carrier resolvers and path MTU differ.

If all five hold and the page still hangs, the next suspect is the forwarder itself: compare a captured request against a captured response and look for a missing byte count or a half-closed socket. The VpnService.Builder reference covers the route, DNS and MTU contracts, and protect() is a separate VpnService call rather than a builder option; a connected interface only means the first contract succeeded.

Sɛbɛn minnu bɛ tali kɛ