Skip to main content
Blog
IncidentReliability / Notifications / Mobile

Foxl v0.5.0: The Notifications That Were Never Sent

Push notifications had never been delivered to anyone, on any device, since we shipped them - not delayed, zero. Apple hands over a device token once per install, seconds after launch, and we discarded it whenever nobody was signed in yet. Alongside it: files that would not open in the web app, and an idle app quietly making seventy thousand requests a day.

Foxl Team7 min read

Foxl v0.5.0: a device token arriving before sign-in and being discarded, the reason no push notification was ever delivered.
On this page
Reviewed against the shipped implementation and retained tests on July 26, 2026.

Foxl v0.5.0 is mostly a release about things that were quietly not working. The headline is uncomfortable: push notifications had never been delivered to anyone, on any device, since we shipped them. Not delayed, not unreliable - zero. The feature existed end to end, the toggles worked, the server was ready to send, and the number of phones registered to receive anything was zero.

Alongside that: images and documents would not open in the web app, Foxl Notes could not tell you when a transcript finished, and an app sitting idle with nothing on screen was quietly making tens of thousands of requests a day. All four had been shipped and believed to work.

The notification that was never sent

Apple hands an app its device token exactly once per install, through a callback that fires within a second or two of launch. Our code listened for it, read the signed-in user's token so it could say which account the phone belonged to, and posted the pair to the server.

On a fresh install there is no signed-in user a second after launch. You have not typed anything yet. So the handler found no account, returned early, and dropped the device token on the floor - and iOS never offers it again. The one delivery you get, per install, went into a code path that discarded it.

Every layer above that was correct, which is why it survived so long. The preferences screen saved your choices. The server checked them properly before sending. There was simply never a phone in the table to send to, and nothing in the product could show you that. We only found it by querying the production database directly and getting an empty result for every platform.

The fix is to treat the token as a durable fact about the device rather than a moment in a session: store it when it arrives, and upload it when we become able to - at sign-in, on refresh, on the next launch. The two events can now happen in either order, minutes apart, and the phone still ends up registered.

Files that spun forever

Opening a PNG, a Word document or a slide deck in the web app showed a spinner that never resolved, then blamed the transfer. The same files opened instantly from the desktop app, which made it look like a capacity or bandwidth problem with large binaries.

It was neither. When the web app asks your desktop for a file, the request travels through our relay, and the forwarder was attaching the four-character text "null" as the request body - because a missing body had been checked for one way and arrived another. Your desktop's server saw a request claiming to carry JSON, rejected it as malformed, and answered with an error before the code that reads files ever ran.

So every file, of every type and every size, failed identically. The transport was healthy the whole time. We verified the fix by pushing a real PNG, DOCX and PPTX end to end and checking the bytes arrived intact, including files in folders with spaces in the name.

Two clients fighting over one identity

The relay keeps one live connection per client, and closes any older connection that claims to be the same client. That rule exists for a good reason: a phone waking from sleep leaves half-open connections behind, and without pruning, one device accumulated seven of them.

The rule is only safe if clients have distinct identities, and ours did not. Every web client announced itself with the same hardcoded name, because the only code that generated a real one lived in a module that was never called. So a second browser tab would disconnect the first, the first would reconnect and disconnect the second, and the two would trade places forever.

Each cycle costs a connection and a status check. With a tab and a phone left open on an app nobody was touching, that came to roughly seventy thousand requests a day - enough to consume most of a daily platform quota with the screen asleep. A second bug hid the first: the retry backoff reset on every successful connection, and in a fight every connection succeeds briefly before being killed, so the backoff never grew and the loop ran at full speed indefinitely.

Clients now get a per-tab identity, and the backoff only resets for a connection that stays up long enough to prove itself.

Foxl Notes tells you when it is done

Transcribing an imported recording takes about as long as the audio. The natural thing to do is start it and put the phone down, which was exactly when Notes could not tell you anything - it was the only Foxl product with no notifications at all.

Notes now has its own notification category, on by default, and a finished import reaches you when you have looked away. While we were in there, importing gained the feedback it was missing: a progress bar with the file name and a live percentage, an explanation that it takes about as long as the recording, and a confirmation naming the note it created. Phones can start an import at all now - previously it needed drag-and-drop or a desktop menu, so the feature was unreachable on mobile even though transcription itself worked there.

One related fix: an import that found no speech - a silent stretch, music only, or audio in a different language than the transcription setting - used to produce a message and nothing else. No note, no kept audio, no way to retry without picking the file again. The note is now always created with its audio attached, so an empty transcript is something you can edit or re-run rather than a dead end.

Why these were hard to see

Every one of these had shipped and been believed to work, and they share a shape worth naming: each failed in a place with no way to report itself.

A discarded device token produces silence. A rejected request body produces a spinner. Two clients evicting each other produces traffic nobody is watching. A notification that is never sent looks exactly like a notification you have not earned yet. None of them throws an error where a person can see it, and none of them is visible from inside the product - the notification one needed a database query to find, and the request storm needed a bill.

The lesson we are taking is narrower than "add more monitoring". It is that a feature is not shipped when the code path exists; it is shipped when something has actually arrived at the other end and you have looked. For notifications that means checking a phone is registered, not that the send function was called.

Also in this release

A number of smaller corrections, mostly mobile: menu text was unreadable in dark mode because it used the system's colour rather than the app's; the chat menu in a browser opened underneath the button that opened it, so the same click landed on the first menu row and dismissed it; a dialog's close button was a sixteen-point target sitting inside the status bar, too small and too high to press; provider and model names truncated to "foxl..." and "Opus..." because the badge beside them refused to give up any width. The Code-tasks notification toggle looked like it saved and silently reverted, because it was sent under a name the server did not recognise.

Desktop, web, and mobile all move to 0.5.0 together, as always.

References and further reading

  1. Foxl changelogRelease
  2. Foxl NotesDocumentation