Skip to main contentSkip to content
Blog
IncidentReliability / Notifications / Mobile

Foxl v0.5.0: The Notifications That Were Never Sent

Push notifications had never reached a single device since we shipped them. Apple hands over a device token once per install, seconds after launch, and we discarded it whenever nobody was signed in yet. Also in v0.5.0: files that would not open in the web app, and an idle app making about seventy thousand requests a day.

Foxl Team7 min read

A muted bell beside a device token dropping into a bin: push notifications that never reached a device because the token was thrown away.
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 not working and gave no sign of it. The worst of them is push notifications, which had never been delivered to anyone, on any device, since we shipped them. 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 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, which iOS never offers again. The only copy of the token an install receives 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 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 each one failed somewhere that had no way to report the failure.

A discarded device token produces silence, a rejected request body produces a spinner, and two clients evicting each other produce traffic nobody is watching. A notification that is never sent looks exactly like one 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": we now count a feature as shipped when something has arrived at the other end and someone has checked, rather than when the code path exists. For notifications that means checking that 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 then 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