İçeriğe geç
wedevit

October 9, 2026 · 9 min read · software

İlhan Buğra Aslan

Your users turned notifications off: how to design a notification system


When users switch notifications off, the problem is usually not the notifications themselves but the absence of any distinction between them. Someone who wants to know when their order ships, and who gets campaign announcements through the same channel, flips one switch and you lose both. The working setup is short to describe. Separate the event from the notification, attach every notification to a category, store preferences as a category and channel grid, declare urgency in the form the operating system understands, and move delivery out of the request path. The sections below cover where each of those five breaks in practice.

An event is one thing, a notification is another

A notification stack that holds up has three layers. At the bottom sits the business event: "order shipped". In the middle sits the notification decision: who hears about this, which category it belongs to, which channel carries it and when. At the top sits delivery: APNs, FCM, SMTP, an SMS provider, the in-app list.

Most codebases have no middle layer. The shipping service calls sendOrderShippedEmail() directly. Nothing looks wrong while that is the only channel. It breaks when you add the second one, because now you need a push call in the same place, a preference check in the same place, and logic in the same place to stop the two channels from repeating each other. By the third channel those checks live in three separate spots and one of them is always out of date.

Separating the middle layer costs you a table and a service. The event is published, the notification service works out recipients and channels, writes a row per delivery and hands it to a queue. The shipping code never learns who got what. Adding a channel becomes work in one place.

Which events deserve a notification?

Filter with three questions. Does it require the user to do something? Is it about something the user owns or follows? Does a delay cause harm? If the answer to all three is no, that record belongs in the in-app list, not in someone's pocket.

There is one rule teams routinely skip: do not notify users about their own actions. Whoever edited the record should not get the "record updated" message. That single filter cuts notification volume noticeably in multi-user B2B applications and takes about an hour to write. In the same spirit, there is no point pushing a notification about a record the user is currently looking at on screen.

Keep the set of categories that default to on narrow: security alerts, mandatory account notices, and work that waits on the user. Everything else should be something the user opted into. A notification that cannot be acted on and cannot be turned off is a complaint waiting its turn.

A preference center is a category and channel grid

The right interface is a grid: categories down the side, channels across the top, a toggle in each cell. Keep categories between six and ten. On a screen with thirty of them nobody finds the one they came for, so they hit "turn everything off" at the top.

Some cells have to be locked: password resets, security alerts, invoices. Show them locked with the reason next to them rather than hiding them, because a hidden preference teaches users that the system ignores their settings.

One small data decision saves a lot of work later: do not materialize the whole grid for every user. Keep defaults in code and write only the cells where a user deviates from the default. Adding a category then costs no backfill across millions of rows, and changing a default moves everyone who never expressed an opinion.

Urgency is a property the operating system knows about

You do not get to decide how much a notification interrupts someone. The user and the OS decide that together. Since iOS 15, every notification carries an interruption level: passive lands quietly in the list, active is the default behavior, timeSensitive breaks through Focus modes, and critical breaks through everything including the mute switch. The last two require an entitlement from Apple that is not handed out to every app. Teams who miss this send their "important" alerts at the normal level and then wonder why they vanish during Focus.

The Android equivalent is notification channels. Since Android 8.0 (API 26) every notification must belong to a channel, and users adjust sound, vibration and importance per channel themselves. A permanent trap lives here: once a channel is created, the app cannot change its behavior programmatically, only its name and description. So if your first release sent everything through a single channel called "General", that decision stays with you until the user reinstalls the app.

The practical conclusion is to sit down and write the channel and category list before the first release. When you are deciding what goes into the first version, you have to separate decisions that are cheap to revisit from ones that are not. Notification channels sit on the expensive side.

When you ask for permission decides how many people keep it on

Android 13 (API 33) made POST_NOTIFICATIONS a runtime permission, and on new installs notifications are off until the user grants it. There was no such barrier on Android before, so the opt-in rate your older app enjoyed does not carry over to new installs.

On iOS there is a way to start without ever showing the system prompt. With provisional authorization, notifications arrive quietly in Notification Center and the user picks "Keep" or "Turn Off" once they have seen one. For low-stakes, high-volume categories this works better than asking up front.

On the web, the Push API needs a service worker and a VAPID key pair. Safari is its own case: web push has been supported since iOS and iPadOS 16.4, but only for web apps added to the Home Screen, and the permission request has to come from a direct user interaction. Code that asks for permission the moment the page loads already performs worst in every browser, since recovering from a denied permission means walking the user into browser settings.

One rule covers most of it: ask at the moment the user has just done something that gives them a reason to want the notification. Asking "shall we notify you about delivery status" right after an order is placed gets a different answer than asking on first launch.

The same event should not produce three notifications

Queues deliver at least once, so every retry on the background job side carries a chance of sending the same notification twice. The fix is a uniqueness constraint on the delivery record: a unique key over (user, event id, category, channel). When the same tuple arrives a second time the write fails and the send is skipped.

Then there is the other problem, which is staleness rather than duplication. If an order's status changes three times in ten minutes, three notifications pile up in someone's pocket and the first two are now wrong. Push providers offer a collapse identifier for this: the apns-collapse-id header on APNs (64 bytes maximum) and collapse_key on FCM. A new notification with the same identifier replaces the undelivered one. Deriving that identifier from the order number is enough in most cases.

A digest is not just a timer

Digests involve two separate decisions, and merging them produces a system nobody can extend. The first is the batching window: how long you wait. The second is presentation: whether the accumulated events go out as one summary message or individually but delayed.

Three window types earn their keep. A fixed window fires at a set moment in the user's local time and suits daily summaries. A sliding window waits for a quiet period after the last event, so one message goes out when the burst ends. A threshold approach lets the first one through immediately and batches whatever follows, which is the most natural fit for comment and chat activity.

Digests also need a check that is easy to forget: before sending, look at whether the user already read those items in the app. A summary that emails back ten things someone already saw teaches them the summary is noise. Send what is still unread when the window closes and drop the rest.

The time zone belongs to the user, not to your server

Store a time zone explicitly for every user. The server clock will not do, and neither will the country field on the account. The classic date and time zone mistakes show up here directly as the hour you send at.

Define quiet hours, and watch one detail: when quiet hours end, do not flush everything at once. Eleven notifications landing back to back at 08:00 are worse than eleven arriving overnight.

Every notification needs an expiry. If someone's phone was off for two days, yesterday's "meeting starts in 15 minutes" has no business being delivered. APNs expresses this with apns-expiration and FCM with ttl. Apply the same check in your own queue too, because the message may also be waiting before it ever reaches the provider.

The in-app feed: fan out on write or on read

There are two designs. Fanning out on write means writing a row per recipient when the event happens. Fanning out on read means keeping one row for the event and working out who sees it at query time.

In most business applications the recipient set is small and fan-out on write is the right call. Read state, per-user ordering and an unread counter all come for free, and the only cost is row count. Social feeds with hundreds of thousands of followers need the opposite, but do not build for that problem before you have it.

Think about the unread counter up front as well. On iOS the badge number comes from the server, which means the server has to know that count. "Seen" and "read" are also different things: opening the list is seen, opening the item is read. Teams who collapse both into one field lose every notification the moment a user glances at the list.

"Sent" is not delivered, and delivered is not read

A 200 from APNs or FCM means your notification was queued, not delivered. If the device is off, off the network or the user revoked permission, the outcome changes quietly. "Sent" and "delivered" belong on your dashboard as two separate numbers.

Token hygiene is the most neglected part. When an app is uninstalled or a token rotates, the provider returns an invalid token response. Systems that never catch that response and delete the record accumulate a pile of dead tokens over the years and lose the ability to read their own error rate. Provider interfaces have a shelf life too: FCM's legacy HTTP and XMPP endpoints were deprecated on 20 June 2023, shutdown started on 22 July 2024, and HTTP v1 is what you use today. Track migrations like that wherever you already follow breaking changes in interfaces you depend on.

Email deliverability is a world of its own, and authentication, complaint rates and one-click unsubscribe live in the transactional email post. One note belongs here though: turning a category off in your preference center and unsubscribing from an email must write to the same record. Keep them in two places and you will eventually produce a user who keeps receiving exactly what they muted.

Move sending out of the request path, keep copy out of the code

Notifications should not be sent inside the request the user is waiting on. When the provider slows down, that turns into a slow save button. Write an outbox row in the same transaction that records the event, and let a queue pick it up. If the transaction rolls back, the notification was never sent either.

Store messages as a template id plus data rather than finished text. Language is resolved at send time from the user's preference. Support has a need here too: to answer "what exactly did we send this customer", keep the rendered version as well. That record works on the same logic as an audit trail, except a notification body can carry personal data, so give it its own retention period.

Nothing from staging should reach a real customer

This mistake is common and embarrassing. A test environment brought up from a production database copy is perfectly ready to notify every real address and real device token inside it. Give non-production environments a single exit gate: either redirect everything to one catch-all address or drop every send outside an explicit allowlist. Use provider sandbox endpoints. While you are closing environment gaps, put this rule in the code rather than in a config file, since copying the wrong file happens to every team eventually.

A preview screen for QA takes half a day and pays off on every template change: pick a category and channel, see how it renders with sample data, send nothing.

Four numbers worth watching

Notifications per user per day. Treat it as a budget, because it grows quietly as categories get added.

Opt-out rate broken down by category and channel. This is the number that tells you which category is burning a channel. The overall opt-out rate does not.

The gap between sent and delivered, per channel. A sudden widening here is almost always a token or configuration problem rather than anything to do with content.

Task completion rate for users who arrived from a notification. Open rate alone misleads: if the notification does not land users on the right screen, opens look fine while completion does not. Keep all four side by side on the dashboard you already have.

Five checks you can run this week

  1. List every call site in the codebase that sends a notification. How many places send, and how many of them check user preferences?
  2. Write out your category list. Which of these can a user mute individually, and which require muting the whole channel?
  3. How many notification channels does your mobile app define? If everything funnels through one called "General", add category-based channels in the next release.
  4. Is there a uniqueness constraint stopping the same event from producing two notifications? If not, what happens on a retry?
  5. Can your test environment reach a real customer address? If you do not know without trying it, the answer is probably yes.

If one of these has no clear answer, or if users have started muting you in bulk, we can map out your current send flow and category structure together and come out with a concrete plan to fix it.


Need help with this topic?

get in touch →← all posts