How VPN Russia limits a free session to 15 minutes on iOS and macOS: a shared session window in the App Group, a watchdog inside NEPacketTunnelProvider, sleep and wake, and a disconnect that On Demand cannot undo.
Our VPN Russia app has a free plan with a simple rule: 15 minutes of connection, then a 45-minute pause. A subscription removes the limit. The timer sounds trivial, yet it hides one real question: which process should own it? This post shows how it works in our iOS and macOS code, with Swift you can reuse. The Android version is described in a separate post.
Why the timer can't live in the app
The obvious place is the app: start a Timer on connect, call stopVPNTunnel() when it fires. It breaks in three common situations:
- The app goes to the background and iOS suspends it within seconds. A suspended app runs no timers.
- The user swipes the app away or the system kills it for memory. The tunnel keeps running, the timer is gone.
- The tunnel can start without the app at all: from the VPN switch in Settings or by Connect On Demand rules.
The only process that lives exactly as long as the tunnel is the Network Extension itself, the NEPacketTunnelProvider subclass. So the extension owns the timer and stops the tunnel on its own. The app only reads the same state to draw the UI and to refuse a connection during the pause.
A session window instead of a countdown
The extension and the app are separate processes, so the state goes into the App Group's UserDefaults, which both can read. We store two dates, not a remaining duration:
allowedDate— the moment the free session ends;nextReconnectDate— the moment the pause ends and a new session may start.
Dates survive any restart of either process, and they give the main rule for free: reconnecting inside the window does not extend it. Disconnect at minute 10 and connect again, and you still have five minutes left, not fifteen.
final class ConnectionValidator {
static var defaults: UserDefaults {
UserDefaults(suiteName: SharedConfig.k_AppGroup) ?? .standard
}
static var allowedDate: Date? { defaults.object(forKey: "allowedDate") as? Date }
static var nextReconnectDate: Date? { defaults.object(forKey: "nextReconnectDate") as? Date }
/// Connecting is forbidden only between the end of the session
/// and the end of the pause.
static var canConnect: Bool {
if let allowedDate, let nextReconnectDate {
let now = Date.now
if nextReconnectDate > now, allowedDate < now { return false }
}
return true
}
static func newConnection() {
let allowedDate = Date.now.addingTimeInterval(TimeInterval(SharedConfig.k_FreeSessionDurationInSeconds))
let barrierDate = Calendar.current.date(
byAdding: .minute, value: SharedConfig.k_FreeSessionPauseInMinutes, to: allowedDate
)
defaults.set(allowedDate, forKey: "allowedDate")
defaults.set(barrierDate, forKey: "nextReconnectDate")
}
static func resetConnection() {
defaults.set(nil, forKey: "allowedDate")
defaults.set(nil, forKey: "nextReconnectDate")
}
}Nobody wants to wait a quarter of an hour to test a timer, so debug builds use short values:
#if DEBUG
static let k_FreeSessionPauseInMinutes = 1
static let k_FreeSessionDurationInSeconds = 120
#else
static let k_FreeSessionPauseInMinutes = 45
static let k_FreeSessionDurationInSeconds = 900
#endifStarting the watchdog in startTunnel
The watchdog starts once the tunnel is really up — right after setTunnelNetworkSettings succeeds. Subscribers skip it entirely; Archive.isPremium is read from the same App Group.
// inside startTunnel, after the network settings are applied
try await setTunnelNetworkSettings(settings)
addConnectionObserver()
private func addConnectionObserver() {
guard !Archive.isPremium else { return }
perform()
}
private func perform() {
// The first connection opens the window
if ConnectionValidator.allowedDate == nil {
ConnectionValidator.newConnection()
}
guard ConnectionValidator.canConnect else {
// Started during the pause (On Demand, Settings toggle): stop at once
disconnect()
return
}
guard let allowedDate = ConnectionValidator.allowedDate,
let nextReconnectDate = ConnectionValidator.nextReconnectDate else { return }
if nextReconnectDate < .now {
// The old window and its pause are over: start a fresh session
ConnectionValidator.resetConnection()
perform()
} else {
startDisconnectTimer(allowedDate: allowedDate)
}
}Note that the extension opens the window itself. The app may not take part in the start at all, and the limit must still work.
A timer that survives sleep
The first idea is a single timer for the remaining time: deadline: .now() + remaining. Don't. DispatchTime counts uptime, and uptime stops while the device sleeps. Lock the iPhone for an hour, and a timer set for ten minutes will fire ten minutes of awake time later — the session silently grows by the whole sleep.
Instead, the timer ticks every five seconds and compares the wall clock with allowedDate. The tick itself may drift; the comparison never does.
private var disconnectTimer: DispatchSourceTimer?
private func startDisconnectTimer(allowedDate: Date) {
// The extension process can be reused: never leave two timers running
disconnectTimer?.cancel()
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .userInitiated))
timer.schedule(deadline: .now() + 5, repeating: 5, leeway: .seconds(1))
timer.setEventHandler { [weak self] in
guard Date.now >= allowedDate else { return }
self?.disconnectTimer?.cancel()
self?.disconnectTimer = nil
self?.disconnect()
}
timer.resume()
disconnectTimer = timer
}The one-second leeway lets the system batch wakeups, which matters for the battery of an extension that may run for hours.
NEPacketTunnelProvider also tells you when the device goes to sleep and wakes up. Both are good moments to check the deadline without waiting for the next tick:
override func sleep(completionHandler: @escaping () -> Void) {
if !Archive.isPremium,
let allowedDate = ConnectionValidator.allowedDate,
Date.now >= allowedDate {
disconnect()
}
completionHandler()
}
override func wake() {
guard !Archive.isPremium else { return }
if let allowedDate = ConnectionValidator.allowedDate, Date.now >= allowedDate {
disconnect()
}
}Disconnecting so that it stays disconnected
Stopping the VPN core or calling cancelTunnelWithError is not enough: the profile stays switched on, and if it has Connect On Demand rules, iOS brings the tunnel right back. We switch the configuration off instead. The VPN switch in Settings goes off, the system stops the tunnel with the configurationDisabled reason and does not restart it.
private func disconnect() {
// The system will report its own stop reason; remember that
// it was the free limit, for analytics
TunnelStopRecord.markFreeLimit(in: Archive.defaults)
Task {
guard let provider = await NETunnelProviderManager.fetchProvider() else { return }
provider.connection.manager.isEnabled = false
try? await provider.connection.manager.saveToPreferences()
// If a start is still in flight, wait for it before stopping the core
await waitForStartToFinish()
stopActiveCore(context: "disconnect()")
}
}
override func stopTunnel(with reason: NEProviderStopReason) async {
disconnectTimer?.cancel()
disconnectTimer = nil
await waitForStartToFinish()
stopActiveCore(context: "stopTunnel")
}Warning
isEnabled = false turns off the VPN profile, so the next connection from the app must enable it again before calling startVPNTunnel(). Our connect() checks manager.isEnabled and, if it is off, sets it to true and saves the preferences first.
Two details from production. First, cancel the timer in stopTunnel: the user may switch the VPN off a second before the timer fires, and a late disconnect() would race with the normal stop. Second, record why the session ended. The system reports the stop as a disabled configuration, and without a mark of your own, analytics cannot tell a free-limit disconnect from a user who turned the VPN off in Settings.
The app's side: refuse and explain
The app never stops the tunnel because of the limit — the extension does that. But before connecting, it checks the same window and, during the pause, tells the user how long to wait instead of starting a tunnel that would be killed immediately:
if Archive.isPremium || ConnectionValidator.canConnect {
Task { try? await vpnManager.connect() }
} else if let nextReconnectDate = ConnectionValidator.nextReconnectDate,
nextReconnectDate > .now {
let left = Calendar.current.dateComponents([.minute, .second], from: .now, to: nextReconnectDate)
if let minutes = left.minute, let seconds = left.second {
alertIdentifier = .init(choice: .limit(minutes: minutes, seconds: seconds))
}
}Checklist
- The timer belongs to the tunnel extension, not to the app.
- Store the end of the session and the end of the pause as dates in the App Group, not a remaining duration.
- Tick often and compare with the wall clock; one long
DispatchTimetimer stretches by the time spent asleep. - Check the deadline in
sleep()andwake()as well. - Disconnect by disabling the configuration, otherwise On Demand reconnects.
- Cancel the timer in
stopTunneland before scheduling a new one. - Subscribers get no window at all, so cancelling a subscription later doesn't leave them with a stale limit.
- Use short durations in debug builds.
The same logic on Android — with VpnService, DataStore and coroutines instead of an extension and dispatch timers — is in the Android post.
ONLYAPPS Team
Novi Sad, Serbia · onlyapps.org