NetDiag+ 1.4.7 went live this week — the iOS network toolkit from my v1.3 and v1.4 posts, now 27 tools, 13 languages, BSD sockets through C-interop, no private entitlements. This release adds one tool, a Latency Dashboard, and fixes one bug that had been shipping quietly since 1.3. Both taught me more about measurement than the other 25 tools combined.
Why a speed test does not answer "why do I lag"
Most of my users are in Saudi Arabia, Turkey, Egypt and Russia, and the most common support question is some version of "the speed test says 180 Mbps but PUBG is unplayable". Both are true at once. A speed test measures throughput to a CDN edge placed deliberately close to you. A game talks UDP to a datacentre in a region — Bahrain, Frankfurt, Mumbai — and what hurts there is round-trip time and how much it wobbles between packets. Throughput to a nearby edge says nothing about either.
My own app had the same blind spot. Site Reach, the censorship tool from 1.4, measures TLS handshake time to 35 web frontends and sorts them alphabetically; in the Gulf nothing is blocked, so it returns 35 green rows and the worst latency hides mid-alphabet. The Latency Dashboard is the opposite question with the same machinery: what is far from here, and what is unstable?
The catalog is the product
The value is in which endpoints get measured, and that has to be per market — a Saudi gamer does not care about Steam's Frankfurt CM; they care about AWS me-south-1, where PUBG Mobile's Middle East matchmaking lives.
So the table is keyed off the App Store storefront (SKStorefront.countryCode), not the UI language — a Saudi expat in London still wants the Gulf table. Five tables, 13–14 rows each across cloud regions, game ping hosts, CDN edges and carrier resolvers, plus four global anchors as a baseline. Every row carries a port (0 = ICMP, otherwise TCP connect — AWS/GCP/Azure drop ICMP by policy), a confidence and a note saying what the number actually measures:
.init(id: "aws-me-south-1", displayName: "AWS Bahrain",
hostOrIP: "ec2.me-south-1.amazonaws.com", port: 443, category: .cloudRegion,
confidence: .medium, note: "Degraded post Mar 2026 — PUBG-M ME hosted here"),
.init(id: "fortnite-me", displayName: "Fortnite Middle East",
hostOrIP: "ping-me.ds.on.epicgames.com", port: 0, category: .gaming,
confidence: .high, note: nil),
Two curation rules. Do not invent hosts: PUBG Mobile, Free Fire, CoD Mobile, Discord voice and Riot Direct publish no per-region ping hostnames, so the nearest cloud region stands in as a labelled proxy. A failing row is data: AWS Bahrain has been degraded since March 2026 and Valorant MENA moved to Mumbai; a red Bahrain row next to a green Mumbai row is the diagnosis.
Rows sort worst-first by median plus jitter, and the gateway is measured before everything else, alone. If the router already answers in 30 ms, every remote number inherits that and a banner says so instead of blaming geography.
[SCREENSHOT: Latency Dashboard, SA storefront — gateway header, worst-first rows with median / jitter / loss columns, Bahrain "No reply" next to a green Mumbai row]
Measurement engineering: seven rounds against a Mac
I no longer trust a phone-only latency number. The tool was tuned against a MacBook on the same Wi-Fi running the identical probe sequence from a script, in the same minute. Seven device rounds, five defects, each one looked like "the network" until the Mac said otherwise.
Warm-up. First run: 8 of 9 rows "Unstable", jitter 384–402 ms on paths with 56–195 ms medians. The first concurrent chunk was paying ~1.5 s for Network.framework path setup and the radio leaving power save. Fix: probes are lists of attempts ([Double?], nil = no answer) and attempt #1 is discarded whether it answered or not — otherwise a warm-up that misses the 2 s timeout becomes 20% fake loss. One throwaway TCP connect warms the path before the fan-out.
Wi-Fi power-save doze. Gateway 3 ± 1 ms, yet Frankfurt 68 ± 36 against 29 ± 3 from the Mac, and one target swung 38 → 2 → 1 ms of jitter across three back-to-back runs. A gateway reply at 3 ms lands while the radio is still awake; a reply from 30–170 ms away lands after it dozed and waits on the AP for the next beacon. No gateway statistic can predict that, so the fix removes the cause: a second PingService fires ICMP at the gateway every 50 ms during the fan-out (started after the gateway row so the baseline stays untouched). Not cheating: game and voice traffic hold the radio awake anyway, so those are the numbers the user plays on.
Address alternation. Long paths settled (Tokyo jitter 42 → 6) but CDN rows went bimodal: Snapchat median 24, jitter 230 — samples alternating 24, 250, 24, 250. A radio does not do that. A fresh NWConnection by hostname per attempt does: short-TTL CDN names plus Happy Eyeballs racing v6/v4, the winner changing between attempts. The Mac script had resolved once and connected to the IP, so now the app does too (pinnedIPv4: resolve once, connect to the literal, hostname fallback when there is no A record). A TCP-connect time is only an RTT if every attempt hits the same address.
A statistics defect. Residual short-path jitter 16–29 ms vs the Mac's 2–6. TCP rows had 6 attempts → 5 samples → 4 deltas, and sorted[count / 2] on four values is the upper median — the second-largest. One residual spike became "jitter". Fix: 11 attempts everywhere (10 scored, 9 deltas) and a true median. Jitter is the median of consecutive |Δ|, not the mean, and "Unstable" is relative — 12 ms of wobble matters on a 30 ms path and is noise on a 260 ms one:
let median = trueMedian(samples)
let deltas = zip(samples, samples.dropFirst()).map { abs($1 - $0) }
let jitter: Double = deltas.isEmpty ? 0 : trueMedian(deltas)
let unstableAt = max(measurement == .tcpConnect ? 20 : 15, median * 0.2)
let verdict: LatencyResult.Verdict = {
if loss > 0 { return .losing }
if jitter >= unstableAt { return .unstable }
if median >= 60 { return .far }
return .closeStable
}()
One more rule: an ICMP target with zero replies is re-asked over TCP on the port it actually speaks (:53 for resolvers, :443 otherwise) and, if that answers, marked ICMP filtered rather than unreachable. After that the TCP rows matched the Mac to within a few ms. Then round 6 happened.
The bug: two pings that swapped replies
Round 6: every ICMP row read median 0 ms, loss 0%, "Close & stable". Including Fortnite Middle East, which had honestly said "No reply" for the previous five rounds.
The v1.3 post showed how iOS lets you ping without entitlements: socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP). What I did not know then, and what the 50 ms keepalive made impossible to miss, is how Darwin demultiplexes those sockets. On Linux, an unprivileged ICMP socket receives replies matching its identifier. On Darwin, every echo reply reaching the host is delivered to every unprivileged ICMP socket in the process; the kernel does not filter on the identifier you put in the packet.
The 1.3–1.4.5 receive path did one receive() per send and accepted any echo reply:
// PingService, 1.3 – 1.4.5
try sock.send(data: packet, to: dest)
let (data, fromIP) = try sock.receive() // one receive per send
guard let header = ICMPHeader.parse(data),
header.type == ICMPType.echoReply.rawValue // any echo reply will do
else { continue }
With one ping running, that is fine. With a gateway keepalive answering every 50 ms, each row's receive() returned the gateway's reply microseconds after its own send: 0 ms RTT, zero loss, for a host that was not answering at all.
The uncomfortable part: this was not a new bug. Bufferbloat (1.4.5) runs two PingService instances concurrently — gateway and 1.1.1.1 — precisely to compare them, and under load their samples could be swapped: a 3 ms gateway reply booked to the internet leg and vice versa.
The fix lives in PingService so every caller inherits it. Loop on receive() until identifier and sequence match, re-arming SO_RCVTIMEO with the remaining time each iteration — otherwise every skipped foreign reply resets the full timeout:
let sendTime = CFAbsoluteTimeGetCurrent()
let deadline = sendTime + timeout
do {
try sock.send(data: packet, to: dest)
// Accept only our identifier + this sequence; skip the rest until deadline.
while true {
let remaining = deadline - CFAbsoluteTimeGetCurrent()
guard remaining > 0 else { throw SocketError.timeout }
sock.setTimeout(seconds: max(remaining, 0.001)) // 0 would block forever
let (data, fromIP) = try sock.receive()
let recvTime = CFAbsoluteTimeGetCurrent()
guard let header = ICMPHeader.parse(data),
header.type == ICMPType.echoReply.rawValue,
header.identifier == identifier,
header.sequenceNumber == UInt16(seq)
else { continue }
continuation.yield(PingResult(sequence: seq, bytes: data.count, ttl: 0,
rtt: recvTime - sendTime, from: fromIP,
timestamp: Date()))
break
}
} catch SocketError.timeout {
// don't yield, count as lost
}
The max(remaining, 0.001) matters: a zero timeval means "no timeout" to SO_RCVTIMEO. Found out the hard way.
Round 7, after the fix: Fortnite Europe 41/4, Fortnite Asia 257/6, Bahrain back to "No reply", TCP rows within 2–5 ms of the Mac. Closed. If you run concurrent ICMP anywhere on Darwin and do not check identifier + sequence on receive, you have this bug.
Bufferbloat: attribution, not just a grade
The bufferbloat tool from 1.4.5 inherits the reply-matching fix, which matters because its whole point is two pings at once. Baseline 5 s, saturate download 10 s, recover 2 s, saturate upload 10 s — with ICMP to the gateway and to 1.1.1.1 throughout at 200 ms spacing. Grading is the Waveform-style scale on latency rise under load (under 5 ms A+, 30 A, 60 B, 200 C, 400 D, else F), but the grade is not the interesting output. The gateway differential is:
// Gateway itself climbs (>30 ms) under load: the queue is between the
// phone and the router. Overrides any ISP-side interpretation.
if let dnGw = dnGwRise, dnGw > 30 { return .localWifi }
if let upGw = upGwRise, upGw > 30 { return .localWifi }
if grade == .aPlus || grade == .a || grade == .b { return .clean }
// Asymmetric: whichever direction is worse ≥ 2x names the queue.
if upN >= dnN * 2 { return .uplinkQueue }
if dnN >= upN * 2 { return .ispDownstream }
return .modemOrLine
Gateway flat while the internet leg climbs: the queue is past the router, ISP side. Gateway itself climbs: it is your Wi-Fi, and complaining to the ISP will not help. Medians and p95 rises only, so one stray 400 ms spike cannot flip a diagnosis. Results are stored as structured History entries and read back oldest-first as a trend, so you can see whether the router firmware update did anything.
[SCREENSHOT: Bufferbloat result — grade, gateway vs internet latency-under-load chart, verdict "Queue is on the ISP side"]
Limitations, and what iOS forbids
Honest list:
- ICMP RTT is not in-game ping. Protocol overhead, server tick rate and matchmaking region sit on top. The row says "network latency to the region where those servers run", never "your PUBG ping". TCP-connect rows are labelled as such and get a higher Unstable floor.
- No raw sockets, so no TTL on replies (the IP header is stripped) and no kernel-side identifier filtering — hence the check in user space. No background sampling either; everything runs while the tool is open.
- No cellular keepalive. The 50 ms trick targets Wi-Fi power save; cellular DRX has no gateway to ping.
-
Carrier portal rows are web front doors.
stc.com.saanswers from a CDN edge 17 ms from Slovenia — not Saudi Arabia. They carry a "not the carrier's network" note until an in-country tester finds real on-net hops. - A phone-only reading is not evidence. Run the same probe from a laptop on the same Wi-Fi before touching a threshold; all five of my defects were invisible without it.
Links
- NetDiag+ on the App Store (free, $2.99 one-time premium): https://apps.apple.com/app/apple-store/id6761954529?pt=128748487&ct=devto&mt=8
- Bufferbloat guide: https://netdiag.online/guides/bufferbloat/
- Game ping and jitter guide: https://netdiag.online/guides/game-ping-jitter/
No accounts; results never leave the device. AdMob + Firebase aggregate analytics as before, gated on consent where required. If you have a Darwin ICMP war story of your own, the comments are open.
Top comments (0)