---
title: "Coordinating Sibling iOS Apps Without a Network"
date: 2026-06-15
tags: [swift, darwin-notifications, iOS, combine, sdk, kernel-ipc]
lang: en
---

# Coordinating Sibling iOS Apps Without a Network

A Swift SDK that turns Darwin notifications into a heartbeat-and-message bus between iOS apps on the same device — no servers, no App Groups, no entitlements.

## Two apps. One device. No good answer.

Imagine two apps on the same iPhone that need to talk. Same device, often the same vendor, often even the same user opening both within seconds of each other. iOS gives you remarkably little to work with. App Groups demand entitlement gymnastics and a shared container. Push notifications require an APNs round-trip you don't want to pay for. URL schemes only fire when an app is foregrounded. The pasteboard is a privacy minefield. Local network discovery now requires explicit permission and a Bonjour service declaration, which immediately puts you in front of a user who is, quite reasonably, suspicious of any app asking to "find devices on your network."

I needed something else. Something that worked when both apps were in memory, that didn't fan out through Apple's servers, didn't require entitlements, didn't pop a permission sheet, and didn't care whether either app had network access. Just two processes, one kernel, one mutual question: _Are you alive? Here's a payload._

This post is about an SDK I wrote called **InternalNotification** that turns a little-known kernel mechanism — Darwin notifications — into a structured pub/sub bus with liveness heartbeats and arbitrary JSON message delivery. I wanted to build a reusable framework for a pub/sub, and it ended up teaching me more about kernel state, Combine threading, and the 4 KB ceiling than I expected to learn that week.

## Darwin notifications, briefly

If you've never met Darwin notifications: they're the same machinery that powers `UIApplicationDidBecomeActiveNotification`, dark-mode change broadcasts, and a hundred other system-wide events. Under the hood, `NSNotificationCenter.darwinNotifyCenter` is a thin Cocoa wrapper around three C functions in `<notify.h>`:

```cpp
// notify.
notify_register_dispatch(name, &token, queue, handler);
notify_post(name);
notify_set_state(token, value);
notify_get_state(token, &value);
```

Anyone on the device can post a notification by name. Anyone listening for that name gets a callback. Each registered token also carries a 64-bit _state_ you can read or write from any process holding a token to the same name. That state survives across reads — the kernel keeps it around as long as at least one token is open.

That last sentence is the entire reason this SDK exists. A 64-bit kernel-managed register addressable by string name is, if you squint, a tiny shared-memory cell. And you can have thousands of them.

## Heartbeats first

Pub/sub without identity is noise. The first job was for apps to be able to announce themselves and answer the question every coordinating peer eventually asks: _who's awake right now?_ Every running InternalNotification client posts a heartbeat on a shared channel every three seconds. The payload of the heartbeat is small but loaded.

```swift
// Heartbeat.swift
private func sendPing(config: INAppConfig) {
    announceIdentity(config: config)

    let timestamp = UInt32(Date().timeIntervalSince1970)
    let state = (UInt64(fingerprint) << 32) | UInt64(timestamp)

    var pingToken: Int32 = 0
    notify_register_check(channel, &pingToken)
    notify_set_state(pingToken, state)
    notify_post(channel)
    notify_cancel(pingToken)
}
```

The state value is a packed `(fingerprint, timestamp)` pair. `fingerprint` is a CRC32 hash of the sender's bundle identifier, which gives me a 32-bit pseudo-unique ID without any pre-shared registry. Two unrelated apps will, in practice, never collide; if they did, the second registration would just observe staler timestamps and resync.

On the receiving side a monitor wakes on each heartbeat, decodes the state, and updates an `aliveApps` set keyed by fingerprint. Apps that miss `timeout` consecutive intervals (default 10 seconds) drop off the set. That set is what every consumer of the SDK ultimately observes.

The interesting part isn't the heartbeat itself. The heartbeat carries no identity beyond the fingerprint — no bundle ID, no API key, none of the metadata a consuming app actually wants. So how does a brand-new monitor know which app fingerprint `0xC3F1A902` belongs to? Lazy resolution.

When the monitor sees a fingerprint it hasn't met before, it does a one-time read against a _different_ set of tokens that the sender has been quietly maintaining since boot. Those tokens are the sender's identity card: `bundleID|apiKey`, chunked into 8-byte slots, written once when the sender comes up.

```swift
// Heartbeat.swift
private func announceIdentity(config: INAppConfig) {
    let payload = "\(config.identifier)|\(config.apikey)"
    let bytes = Array(payload.utf8)
    let chunkCount = (bytes.count + 7) / 8

    var countToken: Int32 = 0
    notify_register_check("\(identifier).\(fingerprint).count", &countToken)
    notify_set_state(countToken, UInt64(chunkCount))
    announceTokens.append(countToken)

    for i in 0..<chunkCount {
        var token: Int32 = 0
        notify_register_check("\(identifier).\(fingerprint).\(i)", &token)

        let start = i * 8
        let end = min(start + 8, bytes.count)
        var value: UInt64 = 0
        for (j, byte) in bytes[start..<end].enumerated() {
            value |= UInt64(byte) << (56 - j * 8)
        }
        notify_set_state(token, value)
        announceTokens.append(token)
    }
}
```

The kernel keeps those tokens alive because the sender holds open handles to them in `announceTokens`. The receiver opens them, reads the state, closes them. Zero broadcast cost, zero shared-secret negotiation. Senders write once at startup; receivers read once on first contact and cache the result.

That asymmetry — pushers push fingerprints, pullers pull identity — is most of what makes the SDK feel light.

## Breaking the 4 KB ceiling

Heartbeats are easy because each one fits in 64 bits. Messages are not. The most natural use case after liveness — broadcasting a structured payload like a device-telemetry snapshot — quickly outgrows the size of a single Darwin state value.

`notify_set_state` takes a `UInt64`. Eight bytes. If you want to ship a 6 KB JSON blob, you need a different plan. The "official" answer is: don't — switch to mach messages or a UNIX socket. But the moment I reached for a socket I was rebuilding the API I'd come to escape from. So I went sideways instead.

Every notification name is its own token. Open thousands of them, treat them like a paged ring buffer in the kernel, and you've turned a single-cell mechanism into a 32 KB-scale transport. `MessageSender` pre-allocates a pool of 4,096 tokens at startup, each owning 8 bytes — a total of 32 KB of addressable kernel state per sender.

```swift
// MessageSender.swift
func send(_ messages: [INMessage]) -> Bool {
    guard let jsonData = try? JSONEncoder().encode(messages) else { return false }
    guard let compressed = compress(jsonData) else { return false }

    let bytes = Array(compressed)
    let chunkCount = (bytes.count + 7) / 8
    guard chunkCount <= poolSize else { return false }

    notify_set_state(countToken, UInt64(chunkCount))
    notify_set_state(sizeToken, UInt64(jsonData.count))
    notify_set_state(compressedSizeToken, UInt64(compressed.count))

    for i in 0..<chunkCount {
        let start = i * 8
        let end = min(start + 8, bytes.count)
        var value: UInt64 = 0
        for (j, byte) in bytes[start..<end].enumerated() {
            value |= UInt64(byte) << (56 - j * 8)
        }
        notify_set_state(pool[i], value)
    }

    var signalToken: Int32 = 0
    notify_register_check("\(channel).msg", &signalToken)
    let state = (UInt64(fingerprint) << 32) | UInt64(chunkCount)
    notify_set_state(signalToken, state)
    notify_post("\(channel).msg")
    notify_cancel(signalToken)
    return true
}
```

The workflow:

1. Encode messages to JSON.
2. Compress with LZFSE. JSON usually shrinks 2–5×, which buys back a lot of the 32 KB budget. LZFSE is built into the OS — deterministic, fast, no third-party dependency, no allocator pressure.
3. Split the compressed byte stream into 8-byte chunks, pack each big-endian into a `UInt64`, write to the pre-allocated token pool.
4. Write three small metadata tokens — `chunkCount`, originalSize, compressedSize — so the receiver can validate and decompress safely.
5. Post a single signal notification on `<central>.msg`. The state on that signal carries `(senderFingerprint, chunkCount)`, which lets receivers both filter out their own messages and know exactly how much to read.

The receiver does the reverse: reads `chunkCount` tokens, reassembles the byte stream, decompresses, decodes, publishes.

The hard part wasn't the chunking — that's a paged buffer, and paged buffers are well-trodden ground. The hard part was sequencing. Darwin notifications are eventually-delivered: if you post the signal before all chunks are written, a fast receiver gets garbage. So I always write all chunks first, then the signal-token state, _then_ post. Receivers always see a coherent snapshot, never a partial one.

I also added throttling on the sender side, because sending fifty messages in a tight loop turned out to be a great way to overflow the kernel's notification queue.

```swift
// Messenger.swift
public func send(_ messages: [INMessage]) {
    let batches: [[INMessage]]
    if messages.count <= maxMessagesPerTransfer {
        batches = [messages]
    } else {
        batches = stride(from: 0, to: messages.count, by: maxMessagesPerTransfer).map {
            Array(messages[$0..<min($0 + maxMessagesPerTransfer, messages.count)])
        }
    }

    sendQueue.async { [weak self] in
        for (i, batch) in batches.enumerated() {
            if i > 0 { Thread.sleep(forTimeInterval: 0.3) }
            _ = self?.sender.send(batch)
        }
    }
}
```

Each batch of up to 50 messages goes out, then I sleep for 300 ms. It's a crude rate-limit but a deliberate one — Darwin's notification queue is not infinite, and a tight loop of 500 posts will start dropping events on the floor. 300 ms drains nicely on every device I've tested.

## The API your app actually calls

Up to this point everything has lived in C-callback land. Tokens, dispatch queues, bitwise packing. The public surface, on purpose, is none of that. `INCenter.start` boots the SDK once, after which sending is fire-and-forget and receiving is a single registered closure. Here is the entire exchange between two cooperating apps on the same device.

```swift
// POSApp.swift
INCenter.start(
    identifier: "com.vendor.shared",
    config: INAppConfig(identifier: "com.vendor.pos", apikey: "team-...")
)

INCenter.shared?.messenger.send([
    INMessage(kind: "order.created", payload: [
        "id": "ord_42",
        "items": 3,
        "total": 49.90,
    ])
])
```

That is the whole producer side. No `await`, no completion handler, no `try`. `.send` encodes the payload, compresses it, lays the chunks into the token pool, and posts the signal. If a receiver is alive on the channel it gets the message within the same dispatch cycle; if not, the message is dropped on the floor. Fire-and-forget is the contract — exactly like Darwin notifications themselves.

```swift
// KitchenApp.swift
INCenter.start(
    identifier: "com.vendor.shared",
    config: INAppConfig(identifier: "com.vendor.kds", apikey: "team-...")
)

INCenter.shared?.messenger.onReceive { messages in
    for msg in messages where msg.kind == "order.created" {
        Kitchen.queue(msg.payload)
    }
}
```

The callback hands you a decoded `[INMessage]` on the main thread and returns. No subscription token to retain, no manual cleanup on app suspend — `INCenter.stop()` cancels every open Darwin token in one call. There's a sibling `onPresence` callback for heartbeat-driven peer changes, and a `send(_:to:)` overload that filters by sender fingerprint when you want a directed message instead of a broadcast. All of it sits on the same machinery: pack, post, drop.

The whole public surface fits in a quick-reference card you could tape next to your monitor: `start`, `stop`, `send`, `onReceive`, `onPresence`. That's the SDK.

## Where it shines

The first real fit for this was a two-iPad setup in a fast-casual restaurant. One iPad runs point-of-sale at the counter; the cashier rings up an order, charges the card, prints the receipt. A second iPad lives on a stand in the kitchen running a Kitchen Display System. Both iPads sit on the same shop Wi-Fi — but that Wi-Fi is also carrying payment-terminal traffic, a guest network, and the occasional Apple TV doing a firmware update. Routing every order through the LAN worked, mostly. The 95th-percentile latency was three to four seconds with a twelve-second tail whenever the access point renegotiated. For an order moving from the counter to the kitchen line, that's the difference between a smooth handoff and a confused line cook waiting on a ticket that already cleared on the customer's end.

The kicker: POS and KDS were always running on a bonded pair. Same vendor (us), same signing identity, same hardware purchased together, mounted three feet apart. The whole network round-trip was solving a problem that didn't actually exist — the two apps were in the same room, in the same hands, sharing a Lightning hub. So I dropped them onto a single iPad with InternalNotification between them and the wire shrank to one device. Latency went from a couple of seconds to consistently under fifty milliseconds, and "the Wi-Fi is being weird again" stopped being a category of support ticket.

The full integration on each side fits on a napkin.

```swift
// POSApp.swift
INCenter.shared?.messenger.send([
    INMessage(kind: "order.fired", payload: order.toDictionary())
])
```

```swift
// KitchenApp.swift
INCenter.shared?.messenger.onReceive { messages in
    for msg in messages where msg.kind == "order.fired" {
        kitchenQueue.append(Ticket(from: msg.payload))
    }
}

```

No HTTP. No auth header. No retry. No socket. Two apps under one developer account talking through the kernel on a device they both already own.

The same shape covers every adjacent case I've reached for since: a clip-on companion app showing live state from the parent, a customer-facing display mirroring a clerk's screen, a debug overlay app that subscribes to events from any of a vendor's other apps without those needing to know it exists. As long as the channel name is shared, it just works.

## Trade-offs

This thing isn't magic. The constraints are real and worth naming.

**Same device only.** Darwin notifications don't cross machines, don't survive AirDrop, won't help with Handoff. If your apps need to coordinate across devices, this isn't the tool.

**32 KB per transport.** The 4,096-token pool is sized for typical telemetry payloads. Larger payloads would need either multiple pools or a different mechanism — and at that point you're probably better off with a real file-backed shared container.

**No delivery guarantees.** Darwin notifications are best-effort. If a receiver is suspended or the queue is hot, posts get coalesced or dropped. The SDK uses heartbeats and signal tokens to detect liveness, but I do not pretend this is reliable messaging. For that you need acks. I've thought about adding a tiny ack channel and decided the use cases I have don't justify the complexity yet.

**iOS background limits.** Darwin notifications fire only while an app is alive enough to receive them. A backgrounded or suspended app receives nothing. The SDK is for the foreground/active-but-not-frontmost overlap window, which is exactly when coordinating-vendor apps tend to need it.

**Fingerprint collisions.** A 32-bit CRC32 collides with non-trivial probability across thousands of unrelated apps. Within a single vendor's app suite — the actual use case — collisions are effectively impossible. If I ever shipped this as a general framework I'd swap CRC32 for a longer hash and a small handshake to detect mismatches.

**Callbacks land on main.** Every `onReceive` and `onPresence` firing hops to the main queue. That's safe and ergonomic, but it means a noisy peer can pump main-thread work. The SDK clamps heartbeat rate and rate-limits batched sends to keep the queue calm; a deliberate adversary in the same device could still DoS it, but the threat model is friendly-vendor apps under the same signing identity, and that holds.

These aren't regrets. They're the contract: a sub-millisecond, zero-network, zero-permission coordination channel for _cooperating_ apps under the same developer's control. Within that contract, it's fast, clean, and ergonomic.

## Closing

The SDK ended up smaller than I expected — under a thousand lines of Swift — but it sits on a much older, much weirder foundation. Darwin notifications are nearly invisible in the iOS dev community today; most of the documentation that mentions them is from the OS X 10.4 era. Working with them felt a bit like finding an undocumented but stable instruction in a CPU: it's been there forever, nobody's removing it, but very few people use it.

If you ever find yourself trying to coordinate two iOS processes without going over the network, take a look at `<notify.h>`. There's more there than the iOS docs let on. The kernel is, as ever, a more interesting layer than the SDK we put on top of it.

## References

- Apple — `notify_post` and the `notify(3)` man page (`man 3 notify` on macOS) for the full Darwin notification API.
- Apple — [Compression framework / LZFSE](https://developer.apple.com/documentation/compression), the built-in compressor used on the wire.
- Apple — [Combine](https://developer.apple.com/documentation/combine), used internally to coordinate state before it's exposed as plain callbacks.
- Apple — [Darwin Notify Center via DistributedNotificationCenter](https://developer.apple.com/documentation/foundation/distributednotificationcenter) for the macOS sibling of the same mechanism.
