Comments and docs kept dating themselves: when a constraint was set, when a pair was verified, what a limit was before some day, which day a subtree was split out. A reader wants the current state, and the date goes stale faster than the sentence around it. Kept, because they are data rather than narration: ISO timestamps in test fixtures, the certificate expiry in project.yml, which an operator acts on, and docs/BUGS-2026-09-01.md, whose name is the record. ⚠️ The first pass collapsed indentation. A whitespace rule meant for prose matched YAML list items and markdown continuation lines, and project.yml stopped parsing: "did not find expected key: - path: Sources". The tests still passed, because they ran against the project generated before the break, which is exactly how this would reach somebody else. Reverted and redone without touching leading whitespace, then xcodegen was run to prove the file parses. 764 XCTest and 75 Swift Testing pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.4 KiB
Delivery without Google or Apple in the middle
Constraint set by the owner: no third party on the delivery path. That rules out FCM and rules out APNs. It does not rule out fast delivery, but it does rule out one thing completely, and that is worth stating before anything else.
The one thing this constraint costs
On iOS, a closed app cannot be woken without APNs. There is no alternative.
iOS suspends a backgrounded process and tears down its sockets. A long-lived connection, a NATS client, a foreground service, none of these exist on iOS. Apple provides exactly one wake-up mechanism for a suspended app and it is APNs.
So "no Apple in the middle" means, precisely: on iPhone, messages arrive when the user opens the app. Not late, immediately on open, but not while it is closed. That is an OS decision, not something the relay can engineer around.
The honest options are:
- Accept it. Say so plainly in the app and on the site: "Flamenet does not use Apple's push service. Messages arrive when you open the app." For a product whose pitch is that no third party is involved, that sentence is an asset rather than an apology.
- Use APNs anyway for a content-free wake-up. Apple learns that a device has mail and when, not from whom, not what. That is a smaller leak than FCM on Android (Google correlates far more), but it is still a third party.
Android has no such limit. A foreground service can hold a connection with nobody in between. Cost: a permanent "Flamenet is running" notification, real battery use, and aggressive OEM background-killers (Xiaomi, Huawei, Samsung) that will sever it anyway.
What actually fixes "I had to hit refresh", and needs no third party
Today E2EInbox polls every 5 seconds while the app is open
(pollInterval: 5_000_000_000). That is both slow and wasteful. Replace it with
long-polling, and both get better at once:
GET /e2e/poll?after=<cursor> # server holds the request open ~30–60s,
# answers the moment something lands
| 5s polling | long-poll | |
|---|---|---|
| latency | up to 5s | ~immediate |
| requests per device | ~12/min | ~1–2/min |
| battery | a wake every 5s | one idle socket |
This is strictly better on every axis and involves nobody. It is the single highest-value change here, it is a relay + client change only, and it makes the app feel like it pushes whenever it is open, which covers the whole iOS story and most of the Android one.
Held requests need care: a timeout below the proxy's idle timeout (Traefik and Cloudflare both cut long connections), a cursor so nothing is missed between requests, and a cap on concurrent held requests per device.
SSE or a WebSocket are the same idea with a persistent connection instead of a re-issued request. Prefer long-poll first: it survives proxies and mobile network transitions with far less code.
Where NATS fits, and where it does not
NATS does not deliver to phones. It is server-side plumbing. A phone still needs its own live connection, so NATS changes nothing about the iOS constraint above. Adding it as "the push server" would be solving the wrong half.
What it does solve is fan-out behind the long-poll. With many held requests, something has to notice a new message and wake exactly the right ones. Three ways, in ascending cost:
- Poll Postgres from each held request. Simplest, and fine at this scale, a held request checking once a second is still 5× less DB load than the current client polling, for far better latency.
- Postgres
LISTEN/NOTIFY. The send issuesNOTIFY user_42; held requests wake instantly. No new infrastructure at all: you already run Postgres. Notifications are not durable: a listener that is not connected misses them, so the cursor query on reconnect stays load-bearing. - NATS. Proper pub/sub, durable with JetStream, scales across relays and fits the federation work already in the tree. It is also another service to run, monitor, secure and back up on a box that has had one intrusion and a volume-prune incident.
Recommendation: 2, not 3, today. LISTEN/NOTIFY gets the same felt
result with zero new moving parts. Reach for NATS when there are enough
concurrent connections that one Postgres cannot hold them, or when relay-to-relay
fan-out needs it, not before.
Sequencing
- Long-poll
/e2e/pollwith a cursor, plus the client switch from the 5s loop. Biggest win, no third party, no new infrastructure. LISTEN/NOTIFYbehind it so held requests wake instantly instead of polling the database.- Android foreground service holding the long-poll while backgrounded, opt-in, with the persistent notification explained honestly.
- iOS: decide between "no background delivery, and we say so" and "content-free APNs". Nothing else exists.
- NATS only if and when 1–3 stop scaling.
Steps 1 and 2 together are the small piece of work that removes the refresh button. Step 3 is a real but contained piece. Step 4 is a product decision, not an engineering one.
What none of this changes
- The crypto, the ratchet, and the wire format are untouched.
NotificationManageralready draws the banner; only the wake-up changes.- The relay still holds ciphertext it cannot read, so any notification it triggers is content-free by construction, the device decrypts and writes the real banner locally.