Keeping restaurant iPads in sync without a router Published October 16, 2026 · Updated October 17, 2026
The problem When the power goes out, the router goes with it, and so does the internet. If that happens, restaurants that rely on online booking can't see their bookings. In a bigger restaurant with several host iPads and/or a guest kiosk, it's worse: nothing stays in sync, so every stand is working from a different picture of the room.
We build TableLink, reservations and table management for restaurants, with an iPad and iPhone app for the host stand and an iPad guest kiosk. This is how we made it keep working when the internet doesn't.
Step one: every device works on its own First we made each device work on its own, like pen and paper. Each host device keeps the book: today, the last 7 days and the next 22, 30 days in all. With no connection at all, a host can still seat, move, combine and separate tables and run the waitlist. Editing a saved floor layout still needs the internet.
The book is a copy of the restaurant's reservations, tables and waitlist, saved from the cloud into the app's own storage. The work is different: when there's no connection, every seat, move and waitlist change goes into a log in a SQLite database on the device, one per restaurant, written by our own Swift code. A change counts as saved only once SQLite has it on disk, not just handed to iOS, so it survives the app closing or the iPad shutting down.
We had other options. The web app already queued offline changes in the browser's database, IndexedDB, but that queue skipped changes it didn't recognize and couldn't carry one device's signed changes for another. On iOS we've also seen IndexedDB simply never answer. We looked at sync products like Couchbase Lite and Firebase, but their standard sync doesn't sign each change on the device that made it, and we needed that so a host's phone could carry an iPad's work and the server could still check where it came from. So the restaurant rules stay in one TypeScript codebase for the browser, iPad and iPhone, and the storage, the signing and the device-to-device link are native Swift.
Step two: keeping the devices in sync without a router We first thought of syncing over the restaurant's Wi-Fi. But if the power goes out and the router goes down, you're back where you started. Restaurants aren't IT shops, so we couldn't count on a backup battery or a second router either, and neither helps an iPad out on the patio, beyond the router's range.
So the devices talk to each other directly over Apple's peer-to-peer Wi-Fi, the same kind of link AirDrop uses, with no router in the middle. We use Apple's Network framework, with Bonjour to find the other devices and mutual TLS (Transport Layer Security) between them: the encryption secure websites use, with both devices proving who they are. Each device gets a certificate for its restaurant when it is set up, so a device from another restaurant can't join.
Each device links to a few neighbors, chosen the same way on every device from the restaurant's device list, so they form a loop with no leader. If one device drops off, the rest stay joined. Apple doesn't publish a limit on how many devices can link like this, and we haven't measured one yet, so each device keeps at most four links for now.
Try it yourself This is the bare setup for iPhones and iPads to find each other and connect with no router. It's all standard Apple code. Ours adds encryption and the restaurant check on top.
import Network
let serviceType = "_myapp._tcp"
// The same settings for the listener, the browser and every connection.
func peerToPeerParameters() -> NWParameters {
let parameters = NWParameters.tcp
parameters.includePeerToPeer = true // allow the AirDrop-style link, no router needed
return parameters
}
// deviceName must be unique to each device, for example a UUID you save once.
// Every connection, incoming or outgoing, goes to onConnection. Keep it there and use it.
// Keep the listener and browser alive for as long as you want to find and be found.
func startPeerToPeer(deviceName: String,
onConnection: @escaping (NWConnection) -> Void) throws -> (NWListener, NWBrowser) {
// 1. Advertise this device.
let listener = try NWListener(using: peerToPeerParameters())
listener.service = NWListener.Service(name: deviceName, type: serviceType)
listener.newConnectionHandler = { connection in
connection.start(queue: .main)
onConnection(connection)
}
listener.start(queue: .main)
// 2. Find the other devices.
let browser = NWBrowser(for: .bonjour(type: serviceType, domain: nil),
using: peerToPeerParameters())
browser.stateUpdateHandler = { state in
if case .waiting(let error) = state {
print("Browser waiting: \(error)") // a denied local network prompt lands here
}
}
browser.browseResultsChangedHandler = { _, changes in
for case .added(let result) in changes {
// One side of each pair dials: the lower name calls the higher one.
// That also skips this device's own listener, which the browser finds too.
guard case .service(let name, _, _, _) = result.endpoint, deviceName < name else { continue }
let connection = NWConnection(to: result.endpoint, using: peerToPeerParameters())
connection.start(queue: .main)
onConnection(connection)
}
}
browser.start(queue: .main)
return (listener, browser)
}The app also has to declare the service and say why it wants the local network, in Info.plist:
<key>NSBonjourServices</key>
<array>
<string>_myapp._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Finds your other devices nearby.</string>Four things that cost us time:
Turn peer-to-peer on everywhere. includePeerToPeer is off by default and is set per object: the listener, the browser and every connection each need it. Any one left off won't use the peer-to-peer link.
Declare the service. If the service type isn't listed under NSBonjourServices, browsing fails with a NoAuth (-65555) error.
A denied prompt doesn't fail. If someone taps Don't Allow on the local network prompt, the browser sits in .waiting with a policy error, so code that only checks for .failed waits forever. After they turn access on in Settings, start a new browser. The old one keeps the denial.
Browsing wakes the radio. A browser with peer-to-peer on brings up that radio by itself, before it finds anyone. Cancel it when you don't need it.
Getting it to actually work Early on, an iPhone that was linked to the iPads lost its cell service. We suspected the peer-to-peer radio and played it safe: a device on the restaurant's Wi-Fi wouldn't use peer-to-peer at all, and a device off it would stop searching after three quiet minutes.
On September 25 we tested it on our own iPad and iPhone. With both off the Wi-Fi, they linked. We added a waitlist party on the iPad and removed it, and both changes reached the cloud through the phone's cellular connection in under a second. A change made in a web browser came back down to the iPad through the phone.
Then we left the phone on the Wi-Fi and took the iPad off it, like a host walking out of range. The iPad found nothing. Our own safety rule meant the phone wasn't listening.
The lost cell service turned out to be a Verizon outage, and Apple's developer support told us peer-to-peer Wi-Fi and cellular don't interfere with each other. So that evening we reversed the rule: every device always listens, and the device that walks away does the searching until it finds someone.
When the same change arrives twice When the internet comes back, one change can reach the cloud twice: once from the device that made it, and once from the phone that carried it. Every change keeps its original ID, so the server applies it once and gives the second copy the same answer. We commit each change in its own database transaction rather than in batches, because one duplicate in a batch would cancel every change beside it, and they would all be sent again with the same duplicate.
When two hosts seat the same table Sometimes two hosts really do clash. A CRDT (conflict-free replicated data type, a way of storing data so everyone's edits merge on their own) could make the devices agree with each other, but it can't decide who actually gets table 12. A person has to.
Say two hosts' devices can't reach each other, and both see table 12 as open. Host A seats the Smiths; host B seats the Joneses. Each change carries the version of the table it saw. A's change reaches the cloud first and is accepted. B's arrives with the old version and is refused. B's floor then shows the Smiths at table 12, and a card at the top of B's Reservations tab says the seating wasn't saved and where to look. B seats the Joneses somewhere else and taps "I've handled this."
This mostly happens while devices can't reach each other. Linked devices see each other's changes as they're made, so two hosts would have to tap at almost the same moment.
Step three: one phone carries the room If one of your host devices is an iPhone on cellular, running TableLink, the app sends the room's work through it on its own. Nobody has to stop in the middle of service to set up a hotspot. You still can; this is quicker.
The devices tell each other which of them can reach the cloud, and the app finds a path to the phone over whatever links are up, even through another iPad in between. The phone uploads everyone's changes and brings new online bookings back down. Only a host device can upload the room's work, never the kiosk, even if the kiosk has internet. That's on purpose. The kiosk is the one device in the building any guest can walk up to and use, so it can only add guests to the line and, if the restaurant turns it on, check in reservations. It never gets permission to upload anyone else's work, like seatings and table changes. The phone needs to stay in the room, charged, with the app open. Guest texts go out the same way, through a connected host device. If the app knows nobody can reach the cloud, it refuses to send the text. A "your table is ready" text that can't go out within the restaurant's grace period, 10 minutes by default, is dropped, not sent late.
What broke along the way The kiosk took minutes to notice the internet was gone. With the restaurant Wi-Fi up but the internet down, the kiosk kept trying the cloud even though the host iPads were right there. It waited for three failed sync attempts, a minute apart, before giving up. Now the first request that fails or stalls triggers a quick check, and if that fails too, the kiosk switches to the other devices. In our simulator tests that cut the switch from about 190 seconds to 14 to 17.
Guests jumped the line after the internet came back. Once their upload went through, the kiosk moved its own offline guests behind people a host had added later. The database had the right times; for a moment the kiosk was sorting by upload time instead of the time each guest joined. It now remembers each guest's join time through that handoff.
A crash when the app closed its database. A delayed background task queried the on-device database just as a session was shutting down. Every database method took a lock first, except close(). This was the bad version:
func close() {
if let handle { sqlite3_close_v2(handle) }
handle = nil
}The fix was making close() take the same lock as everything else:
func close() {
transactionLock.lock()
defer { transactionLock.unlock() }
if let handle { sqlite3_close_v2(handle) }
handle = nil
}Our new test crashed the old code five times out of five, and later test runs haven't crashed. That fix is in 1.2.4, which is in beta.
Limits This only works in the TableLink iPad and iPhone apps, not in a browser or on Android. Each device has to be set up once while it's online, with local network access allowed, and the devices need to be in the same room. A device that wasn't set up before the outage can't join until it's back online.