Android Notifications Without Firebase on Duleko
Duleko's Android app shows chats and work requests in the notification bar with no push service, and keeps email inside Brevo's free 300 a day. Here's how.
Until version 1.2, Duleko's Android app only told you something had happened while it was open. A new message, work request or friend request reached you inside the app, by email, or by SMS if you'd opted in, but the phone's notification bar stayed empty. The usual fix is a push service, which on Android means Firebase Cloud Messaging plus a server that sends to it for every event. Duleko doesn't have one, and it runs entirely on free tiers. So now the phone asks instead of being told. This post covers how that works, what it costs, and the other half of the same problem: keeping email inside a free plan's daily allowance.
The phone asks instead of being told
Android's WorkManager runs background jobs for an app even while it's closed, on Android's own schedule. Duleko's job calls one Postgres function, device_alerts(), through Supabase's REST API and gets back two things: the unread alerts this phone hasn't shown yet, and the unread total. It posts each alert to the notification bar and puts the total on the launcher icon.
The job runs every 15 minutes, which is the shortest interval Android allows for repeating work (and later still when the phone is idle), and only when there's a connection. Leaving the app also schedules two one-off checks, 2 and 6 minutes later, because a reply tends to come soon after a message. Leaving again replaces those two checks instead of stacking more. If the app is on screen when the job runs, it does nothing: inside the app everything is already live over Supabase Realtime.
A key per phone, not a login session
The background job can't use the app's sign-in session. The session expires, and refreshing it from two places at once would clash. So at sign-in the app generates a random 32-byte key on the phone and registers it through a function only a signed-in member can call. The database stores only the key's SHA-256 hash, tied to that member's profile.
The job then calls device_alerts() with nothing but Supabase's public anon key and the device key. The table of registered phones has Row Level Security switched on with no policies at all, so the only way in is through these functions. What a device key can read is narrow on purpose: that member's unread alert titles and their unread count, not their messages, phone number or profile.
Sign-out unregisters the key using the key alone, so it works even after the session has expired. An unknown key gets back an empty answer and the job stays quiet, which is how a phone signed out from somewhere else goes silent. A phone belongs to whoever signed in on it last, starting from that moment, so a new account doesn't inherit a backlog of old alerts. Phones that haven't checked in for 90 days, usually because the app was uninstalled, are cleared out.
No missed alerts and no repeats
Polling on a schedule can fail in two ways: it can miss an alert that was committed a moment after the last check had already looked, and it can show the same alert twice. Duleko accepts the second to rule out the first. Each check asks for everything since five minutes before the previous check, so an alert that landed late is still picked up, and the phone remembers the last 200 alert ids it has shown and silently drops any it has already seen. Each check returns at most 30 alerts, oldest first.
Making it feel like a real notification
Polling decides when the phone finds out. Everything else is about how the notification behaves once it arrives:
- Language: titles and text come back in the member's own language, English or Nepali, chosen by the database from their profile.
- Chat reads like Messenger: the sender's name as the title and their words below it. There's one notification per person, so a newer message replaces the older one, and opening that chat in the app clears it.
- Four notification categories (messages, work, friends and updates) show up in Android settings, so a member can mute one kind without muting all of them. Messages and work requests are high priority.
- Tapping a notification opens the same screen as tapping it in the Alerts tab, and marks it read. If the tap is what launched the app, it's held until the app is ready to handle it, so it still lands on the right screen.
- The number on the launcher icon follows the same rules as the badges inside the app: unread alerts plus conversations whose newest message is unread and from the other person. A conversation counts once, not once per message.
The cost: minutes, not seconds
The trade-off is delay. An alert can reach the notification bar up to 15 minutes after it was sent, and longer if the phone is dozing. For a work request that's usually fine. For a back-and-forth chat it's the weak spot, which is why the checks 2 and 6 minutes after leaving the app exist. Real push is still on the roadmap. When it comes, it only replaces how the phone finds out there's something new; the device keys, the per-member alert query and the way notifications look all stay the same.
Keeping email inside 300 a day
Alerts can also go out by email, through the outbox that a Supabase edge function drains once a minute. Brevo's free plan sends 300 emails a day, reset at 00:00 UTC, which is 5:45 in the morning in Nepal. That limit is easy to hit now that staff can post on the new notice board: every notice goes to every member as an alert and an email, so with enough members, one notice can use up a whole day's allowance.
A trigger on the delivery table counts today's queued, sending and sent emails as each new one is queued. Once the day's limit is reached, the email is marked 'skipped' and never sent. The in-app alert is created as always, so the member still sees it in the app and, now, on their phone. Because two notices going out at the same moment must not both see room for the last few emails, the trigger takes a transaction-level advisory lock and counts one email at a time.
The limit is 280, not 300. Sign-up and password-reset codes can go out through the same Brevo account, and they must never be the emails that miss out. The number is a row in a settings table, so it can be raised for a paid plan or removed without a deploy. As a backstop, if Brevo itself answers that the credits are used up (a 402), that email and the rest of that run's emails are skipped instead of being retried. A 429 is different: it's a short burst limit that clears by itself, so those are still retried. SMS carries on either way.
Both halves come from the same constraint: free tiers, no always-on server and no push service. Instead of waiting for one, the database decides what each phone is allowed to see and how many emails a day can go out, and the phone and the outbox work within those rules. Phone alerts are also the one channel that costs nothing per message, so on a day when the email allowance runs out, the notification bar still gets every alert.
