A practical field guide for iOS developers building VPN and Network Extension apps with NEPacketTunnelProvider.
Hi everyone,
I've spent a good amount of time building and maintaining iOS apps with a Network Extension layer, and I keep seeing the same debugging questions: "My tunnel is stuck on Connecting", "my breakpoint in startTunnel never hits", "the VPN just drops after a few minutes with no crash." Packet Tunnel Providers are hard to debug because they don't behave like a normal app. This post collects the setup, tools and habits that saved me the most time. I'd love to hear what has worked for you too.
1. Why this is harder than normal iOS debugging
A packet tunnel runs as an app extension, which means:
- It's a separate process. Your host app and the extension have different lifecycles, and Xcode attaches to only one of them by default.
- The system controls its lifecycle. iOS launches and kills the extension, not you. It can be started, stopped or restarted without your app being in the foreground.
- It needs a real device. Packet tunnel providers don't run in the iOS Simulator, so every test cycle goes through hardware.
- It has tight resource limits. Memory in particular is capped much lower than for a normal app (more in section 7).
- Failures are often silent. A jetsam kill or an uncalled completion handler gives you no alert, just a VPN that quietly stops working.
2. Pre-flight checklist (check this before debugging code)
A surprising number of "bugs" are configuration problems. Before attaching a debugger, confirm:
- The app and the extension both have the Network Extension capability, with
packet-tunnel-providerenabled. - Both targets share the same App Group identifier if you pass data between them, and the provisioning profiles include it.
- The extension's
NSExtensionPrincipalClasspoints at yourNEPacketTunnelProvidersubclass, with the correct module name prefix. - The bundle ID of the extension is prefixed by the host app's bundle ID.
- You are on a physical device, signed with a profile that actually includes the entitlement. A build that works in debug but fails from TestFlight is often a profile or entitlement mismatch.
3. Attaching the debugger to the extension
If your breakpoint in startTunnel never hits, you're probably attached to the wrong process, or the extension launched before the debugger attached. There are three approaches:
Option A: Attach after the tunnel is running
Run the host app on the device, start the VPN, then in Xcode choose Debug > Attach to Process by PID or Name... and enter the extension's executable name. This is good for inspecting a tunnel that is already up, but it misses anything that happens during startup.
Option B: Run the extension scheme and wait for launch (best for startup bugs)
- Select the extension's scheme in Xcode and edit it (Run > Info).
- Under Launch, choose Wait for the executable to be launched.
- Press Run. Xcode waits. Now start the VPN from your host app on the device.
- Xcode attaches the moment the extension process starts, so breakpoints in
startTunnelhit.
Option C: Give yourself a window to attach (debug builds only)
override func startTunnel(options: [String : NSObject]?,
completionHandler: @escaping (Error?) -> Void) {
#if DEBUG
Thread.sleep(forTimeInterval: 8) // time to Attach to Process; never ship this
#endif
// ...
}
Gotcha: stale extension processes. After you rebuild, the old extension process can still be running, so you end up debugging the previous binary. Disconnect the VPN (or toggle it off in Settings) before each new run so the system launches your fresh build.
4. Logging that actually works
Breakpoints stop the world, and a tunnel has network timers, keepalives and server handshakes that don't like being paused. For most problems, good logging beats stepping through code. Use the unified logging system with a dedicated subsystem and categories:
import OSLog
enum Log {
static let tunnel = Logger(subsystem: "com.example.app.tunnel", category: "lifecycle")
static let packets = Logger(subsystem: "com.example.app.tunnel", category: "packets")
static let crypto = Logger(subsystem: "com.example.app.tunnel", category: "crypto")
}
Log.tunnel.info("startTunnel called")
Log.tunnel.error("handshake failed: \(error.localizedDescription, privacy: .public)")
Watch out for redaction. When no debugger is attached, dynamic string values in unified logs show up as <private>. Mark non-sensitive values with privacy: .public so they're readable in the field, and never mark keys, tokens, IPs of users or other sensitive data as public.
Where to read the logs
- Xcode console while attached (full values, no redaction).
- Console.app on a Mac with the device connected: select the device, filter by your subsystem or by the extension's process name, and enable info/debug messages.
- A sysdiagnose from the device when a user reports an issue you can't reproduce.
A fallback: log to a file in the App Group
When you need logs from a device that isn't connected to a Mac, write a small rotating log into the shared container so your host app can display or export it:
final class FileLog {
static let shared = FileLog()
private let queue = DispatchQueue(label: "tunnel.filelog")
private let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.example.app")!
.appendingPathComponent("tunnel.log")
func write(_ line: String) {
queue.async {
let entry = "\(Date()) \(line)\n"
if let handle = try? FileHandle(forWritingTo: self.url) {
handle.seekToEndOfFile()
handle.write(Data(entry.utf8))
try? handle.close()
} else {
try? Data(entry.utf8).write(to: self.url)
}
}
}
}
Keep it safe: cap the file size and rotate it, keep it off the main path of packet processing, and never write secrets, keys or user traffic contents into it.
5. Lifecycle mistakes that cause "stuck on Connecting"
The most common cause of a tunnel that hangs in the Connecting state is a completion handler that never gets called. Every code path through startTunnel, success or failure, must call it exactly once.
override func startTunnel(options: [String : NSObject]?,
completionHandler: @escaping (Error?) -> Void) {
Log.tunnel.info("startTunnel")
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "203.0.113.1")
let ipv4 = NEIPv4Settings(addresses: ["10.0.0.2"], subnetMasks: ["255.255.255.0"])
ipv4.includedRoutes = [NEIPv4Route.default()]
settings.ipv4Settings = ipv4
settings.dnsSettings = NEDNSSettings(servers: ["1.1.1.1"])
settings.mtu = 1400
setTunnelNetworkSettings(settings) { [weak self] error in
if let error = error {
Log.tunnel.error("settings failed: \(error.localizedDescription, privacy: .public)")
completionHandler(error) // always complete, even on failure
return
}
self?.startPacketLoop()
completionHandler(nil)
}
}
override func stopTunnel(with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void) {
Log.tunnel.info("stopTunnel, reason: \(reason.rawValue)")
// close sockets, cancel timers, free buffers
completionHandler()
}
Other lifecycle habits worth building in:
-
Log entry and exit of every lifecycle method, with the
NEProviderStopReasoninstopTunnel. It tells you whether the user, the system, a config change or an error stopped the tunnel. -
Don't do slow, blocking work (DNS lookups, big file reads) on the call path of
startTunnel. The system won't wait forever. -
From the host app, watch status with
NEVPNStatusDidChangeand on iOS 16 and later readfetchLastDisconnectErrorto see why the last session ended.
NotificationCenter.default.addObserver(
forName: .NEVPNStatusDidChange, object: manager.connection, queue: .main) { _ in
print("VPN status:", manager.connection.status.rawValue)
}
manager.connection.fetchLastDisconnectError { error in
print("Last disconnect error:", error?.localizedDescription ?? "none")
}
6. Connected, but no traffic
If the status shows Connected but nothing loads, the problem is usually in routing, DNS or the packet loop:
-
Packet loop not re-armed.
readPacketsdelivers one batch and then stops. You must call it again from inside the handler, otherwise traffic silently stalls after the first packets. -
Routes and DNS. Check
includedRoutes,excludedRoutesand DNS servers. A wrong default route or an unreachable DNS server looks exactly like a broken tunnel. - MTU. If small requests work but larger pages hang, suspect an MTU or fragmentation issue and test with a lower value.
-
Protocol family. Make sure the protocol numbers you pass to
writePacketsmatch the packet versions (IPv4 vs IPv6).
private func startPacketLoop() {
packetFlow.readPackets { [weak self] packets, protocols in
guard let self = self else { return }
Log.packets.debug("read \(packets.count) packets")
self.forwardToRemote(packets, protocols) // your tunnel logic
self.startPacketLoop() // re-arm, or traffic stops
}
}
7. Memory limits and silent kills
This is the cause behind "the VPN drops after a few minutes and there's no crash." iOS enforces a strict memory limit on network extensions. Historically it was around 15 MB, and it has been higher (around 50 MB) on more recent iOS versions, but you should confirm the limit for the OS versions you support, since it can change. When the extension exceeds it, the system terminates it (a jetsam event) without showing a crash dialog. The VPN simply disappears.
How to find and prevent it
- While attached in Xcode, watch the Memory gauge for the extension process during real traffic, not just at idle.
- Use Instruments (Allocations and Leaks) attached to the extension process to find growth over time.
- Look for JetsamEvent reports under Settings > Privacy & Security > Analytics & Improvements > Analytics Data on the device, or in Xcode's device logs.
- Stream data instead of buffering it, cap queue sizes, and release packet arrays promptly.
- Be careful with large dependencies. Linking a heavy cross-platform runtime or big lookup tables into the extension target can use up most of your budget before a single packet flows.
- Keep logging cheap. Building large strings for every packet adds up fast.
8. Talking to the extension from the app
A very useful trick: add a small diagnostics channel so the host app can ask the running tunnel how it's doing. This gives you live state on a device without any debugger attached.
// Extension side
override func handleAppMessage(_ messageData: Data,
completionHandler: ((Data?) -> Void)?) {
let status = "state=\(currentState) in=\(bytesIn) out=\(bytesOut)"
completionHandler?(status.data(using: .utf8))
}
// App side
if let session = manager.connection as? NETunnelProviderSession {
try? session.sendProviderMessage(Data("status".utf8)) { reply in
if let reply = reply, let text = String(data: reply, encoding: .utf8) {
print("Tunnel says:", text)
}
}
}
You can extend this to report byte counters, last error, reconnect count, current server and memory usage, and to expose a "download tunnel log" button in a hidden debug menu.
9. Crash logs and where to find them
- Xcode: Window > Devices and Simulators > select the device > View Device Logs. Filter by the extension's name.
- On the device: Settings > Privacy & Security > Analytics & Improvements > Analytics Data.
- Crash reporting SDKs: they only cover the extension if you integrate them into the extension target itself, and that costs memory. Measure before and after.
- sysdiagnose: the best option for issues reported by testers, since it captures logs from both the app and the extension.
10. Quick troubleshooting table
| Symptom | Likely cause | What to check |
|---|---|---|
| Stuck on "Connecting" | Completion handler never called, or setTunnelNetworkSettings never returns |
Log every step in startTunnel; make sure all paths call completionHandler
|
| Connected, no internet | Wrong routes or DNS, or packet loop not re-armed |
includedRoutes, DNS servers, readPackets re-arm, MTU |
| Drops after minutes, no crash | Jetsam memory kill | Memory gauge, Instruments, JetsamEvent logs |
| Breakpoints never hit | Wrong process, or extension launched before attach | Scheme option "Wait for the executable to be launched"; disconnect VPN before rerun |
Logs show <private>
|
No debugger attached; dynamic strings redacted | Use privacy: .public for non-sensitive values |
| Works in Xcode, fails in TestFlight | Entitlement, provisioning or App Group mismatch | Signing profiles for both targets; capability list |
| Extension can't read shared data | App Group ID mismatch between targets | Same group identifier in both targets and both profiles |
A short summary
- Check entitlements, App Groups and signing before touching code.
- Use the extension scheme with "Wait for the executable to be launched" to debug startup.
- Prefer structured logging (subsystem and categories) over breakpoints, and add a file-log fallback.
- Call every completion handler on every path, and re-arm
readPackets. - Treat silent disconnects as a memory problem until proven otherwise.
- Add an app-to-extension diagnostics channel early. It pays for itself.
Over to you
I'd like to learn from the community here:
- What is your setup for debugging a tunnel on a device that isn't plugged into your Mac?
- Has anyone found a reliable way to reproduce jetsam kills in a test, instead of waiting for them in the field?
- Are you using
NWConnection, raw sockets, or a custom protocol stack inside the extension, and how did that affect memory and debugging?
Note: platform behaviour, memory limits and API availability change between iOS releases. Please verify details against Apple's current Network Extension documentation for the OS versions you support.
Top comments (0)