Skip to content
macOSDeep Dive Published Updated 8 min readViews unavailable

Bonjour on macOS: Service Discovery, Resolution, and Network Changes

Design resilient macOS Bonjour discovery with Network.framework, service identity, TXT metadata, local-network permission, and reconnection boundaries.

Bonjour is not a directory of machines and it is not a connection protocol. It is Apple’s implementation and integration of zero-configuration networking conventions that let applications advertise and discover named services on a local network. DNS-based Service Discovery (DNS-SD) describes how service instances are named and located; multicast DNS (mDNS) carries many local-link queries and answers without a conventional unicast DNS server. After discovery, an application still has to establish a transport connection, negotiate its application protocol, and decide whether the peer is appropriate.

That separation prevents a common design error: treating a row in a discovered-services list as a permanently reachable, trusted device. A service can disappear, change interfaces, rename after a conflict, or be discovered before the application can connect to it. A robust macOS client keeps discovery state separate from connection state and gives every result a lifecycle.

Pick the right abstraction

For current Swift applications, Network.framework provides service browsing through NWBrowser, Bonjour endpoints through NWEndpoint, and service publication through NWListener.Service. On macOS 26 and later, it also provides NetworkBrowser, NetworkListener, and NetworkConnection, APIs designed around Swift structured concurrency. Prefer those newer types when the deployment target and product needs permit; the callback-oriented NWBrowser/NWListener path remains useful for apps that support earlier macOS releases or need its established API surface. This article’s browser example uses that broader-availability path. Both approaches let a browse result become a connection endpoint without manually extracting an IP address and port. Apple marks the older Foundation NetService and NetServiceBrowser APIs deprecated, and its lower-level dnssd API documentation says most apps should use a higher-level service-discovery API. Use DNS-SD’s C interface only when a concrete requirement needs its lower-level behavior or a cross-platform BSD-style interface.

Choose an application-owned service type that follows the DNS-SD naming convention, such as _catalog-sync._tcp. The type describes the protocol and transport, not a particular product instance. A service name is a human-facing instance label and may be changed to resolve a name collision. The domain scopes the browse; nil commonly asks the system to use its default browsing domain. Do not persist a resolved IP address as the service identity. Persist a stable application identifier from an authenticated application protocol if the product needs durable identity.

Browse as a changing set, not a one-time query

An NWBrowser emits state changes and result-set changes while active. Interpret the latest set as current observations, not as an append-only list. Additions and removals can arrive as the network changes, and a result may describe multiple interfaces. Keep the browser alive for as long as the feature needs discovery, cancel it when the owning screen or service no longer needs updates, and avoid starting duplicate browsers for every view appearance.

The following pattern shows the ownership boundaries. The queue is explicit because Network.framework delivers browser callbacks on the queue passed to start. UI mutation is then handed to the main actor. The application must retain the browser and any connection objects it creates.

import Network
import Foundation

final class ServiceDiscovery {
    private let queue = DispatchQueue(label: "com.example.discovery")
    private var browser: NWBrowser?

    func start(onResults: @escaping @MainActor (Set<NWBrowser.Result>) -> Void) {
        let descriptor = NWBrowser.Descriptor.bonjour(
            type: "_catalog-sync._tcp",
            domain: nil
        )
        let browser = NWBrowser(for: descriptor, using: .tcp)
        self.browser = browser
        browser.browseResultsChangedHandler = { results, _ in
            Task { @MainActor in onResults(results) }
        }
        browser.stateUpdateHandler = { state in
            switch state {
            case .failed(let error):
                NSLog("Bonjour browse failed: %@", String(describing: error))
            default:
                break
            }
        }
        browser.start(queue: queue)
    }

    func stop() {
        browser?.cancel()
        browser = nil
    }
}

This snippet illustrates a browser lifecycle, not a complete connection client. In a real application, ensure the callback cannot outlive its owner or deliver stale UI state. A generation identifier or an owner-isolated task can discard results from a browser that has already been cancelled and replaced. Do not force-cast endpoints or attempt to parse display names into hostnames.

Advertising a listener before its protocol handler is ready creates a discoverable but unusable endpoint. Configure the listener’s service description and callbacks before starting it, then observe both listener state and service-registration changes. A listener in the ready state can accept connections, but Bonjour registration may still be pending; do not present the service as discoverable until registration reports it. NWListener.Service includes the service type, optional name and domain, and optional TXT data. Network.framework can automatically rename a service if the chosen instance name conflicts; if a stable visible name is a product requirement, observe registration changes rather than assuming the requested name was published unchanged.

TXT records are small metadata associated with an instance, not a replacement for a protocol handshake. Include only fields needed to help a client decide whether to connect, such as a protocol revision or a feature hint. Do not put secrets, credentials, long-lived tokens, or sensitive user information in discoverable metadata. Validate every value and treat missing, malformed, or unknown keys as ordinary compatibility cases. DNS-SD metadata is not guaranteed to be fresh at the exact moment a client uses it.

Local-network privacy and service declarations

On macOS 15 and later, Local Network privacy lets users control whether a program may interact with local-network devices. An app that accesses the local network needs an NSLocalNetworkUsageDescription usage string explaining the purpose to the person using the app. Apps whose local-network use registers or browses specific Bonjour service types also declare those types using the NSBonjourServices property-list key. These declarations are distinct from App Sandbox network entitlements: a sandboxed app needs the outgoing-client entitlement when it initiates connections, and the incoming-server entitlement when it listens for inbound connections. Request only the capabilities the feature actually uses; on macOS the multicast entitlement is not required. A missing or denied privacy permission or sandbox capability can prevent expected behavior, and silently retrying forever does not obtain consent.

Test the permission flow with a clean test account or a reset test device and record whether the app was allowed to browse. Do not make a blank or misleading prompt description. If the feature is optional, keep the rest of the application usable when access is denied and provide a setting or explanation that leads the user to the appropriate system controls without trying to modify privacy databases.

TXT data, endpoints, and real connections

The browse result identifies a service endpoint. Use that endpoint to create an NWConnection with the transport and application protocol expected by the service. Let Network.framework resolve the Bonjour endpoint and manage interface selection. A discovered service can move or disappear between browsing and connection establishment, so handle waiting, failed, and cancelled states as normal. A successful connection-ready state still does not prove that the application handshake, authorization, or request succeeded.

If the browse descriptor includes TXT data, parse it as untrusted protocol metadata. Treat TXT key names and values as byte-oriented protocol fields, enforce size and syntax bounds, and define behavior for unknown keys. TXT data is suited to compact descriptive properties; large configuration belongs in a real protocol exchange after connecting. Never use a TXT field as proof of identity or authorization.

Service type, instance name, domain, and interface are useful keys for in-memory reconciliation. They are not all stable across restarts or networks. A laptop can see two services with the same instance name on different interfaces, and a network can contain multiple instances with identical types. Keep the endpoint object supplied by Network.framework instead of deduplicating solely by a localized label.

Operational failure modes

When a service does not appear, separate permission, browse state, link visibility, service publication, and network policy. Confirm the advertiser reports a ready listener and has the correct type. Confirm the browser is active and not failed. Verify both devices are on a network that permits multicast discovery; guest Wi-Fi, client isolation, VLAN boundaries, VPN policy, and multicast filtering can prevent local discovery even while ordinary internet access works. A successful DNS lookup for a public hostname is not evidence that mDNS packets cross the local link.

When discovery works but connection establishment fails, inspect the listener endpoint, firewall policy, interface, and protocol configuration. Do not assume that a service published on one interface is reachable over every interface. When a connection fails after working, close or cancel the old connection and build a bounded retry policy with backoff and cancellation. Continue monitoring browse changes so a disappeared result is removed from user-visible availability, but do not tear down a connection merely because one browse update was delayed.

Acceptance checks

Test an advertiser and browser on the same local network, then on different subnets and on a network with multicast restrictions. Confirm the user sees a meaningful permission request. Change the service instance name to exercise collision behavior, stop and restart the listener, switch Wi-Fi networks, and suspend/resume the client. Verify the visible list converges to the latest result set, stale connections close, retries stop when the feature is no longer active, and a successful transport connection is followed by an application-level handshake. Log state transitions and error domains, but avoid logging private payloads or user-supplied TXT data.

The production contract is modest and precise: Bonjour helps locate a candidate service on a reachable discovery domain. Network.framework establishes a connection to that endpoint. Your protocol, not the discovery record, must establish what the peer can do and whether the interaction is valid.

Related:

Sources:

Comments