The Point Where Notifications Start Getting Ignored — Designing a Delivery System

A few months after a service goes live, the same remark tends to come up.

"There are too many notifications, so I don't look at them."

By the time someone says this, a large share of users have already turned notifications off. Once turned off, notifications are almost never turned back on. When you later need to send an announcement that really matters, it no longer reaches them.

The hard part of a notification system is not the technology for sending. It is deciding what not to send, and enforcing that rule in the system.

This article lays out what has to be decided when designing a notification feature. It covers both service notifications for users and incident alerts for operators.


1. First, filter what gets sent as a notification

When a feature list contains a single line reading "send notifications," dozens of distinct situations are hiding behind it.

Classify the reasons for sending

Type Examples Nature
Confirmation Order received, payment completed Response to a user action
Progress Shipment dispatched, processing status changed Sharing progress
Request Awaiting approval, input required Asks the user to act
Reminder One day before a reservation, expiry approaching Time-based
Announcement Maintenance notice, policy change Everyone or a target group
Promotion Events, recommendations May be unrelated to the user's benefit
Operations Incidents, threshold exceeded For internal staff

This classification is needed because each type has to be handled differently. Confirmation notifications must go out immediately, promotional notifications need limits on time and frequency, and announcements may have to be delivered regardless of opt-outs.

There is also a legal reason for separating out the promotion type. Promotional notifications fall under what the Act on Promotion of Information and Communications Network Utilization and Information Protection (정보통신망 이용촉진 및 정보보호 등에 관한 법률, the "Network Act") calls "advertising information for commercial purposes," and they come with prior consent, labeling, and night-time transmission restrictions. One point to watch: if you append a single line of advertising copy to an informational message, the entire message is treated as advertising information. The guide published by the Broadcasting, Media and Communications Commission (방송미디어통신위원회) and the Korea Internet & Security Agency (KISA, 한국인터넷진흥원) gives the example of an email that sends payment details with an advertisement at the bottom. (Guide to the Network Act for Preventing Illegal Spam, 7th Revised Edition) For that reason, it is safer to fix the type at the template level and block promotional copy from being added to confirmation and progress templates. The specific requirements are covered in Section 4.

Filter out what does not need to be sent

These are the criteria.

  • Does the user need to know right now, or can they check later?
  • Does the user have something to do, or is it just information?
  • Do they already know through another route?
  • Can several items be sent together?

For the result of an action the user took themselves, showing it on screen without a notification is often enough. A push saying "Your application has been submitted" right after the user pressed the submit button is unnecessary.

Conversely, changes that happen while the user is not looking, especially situations where the user has to do something, do need a notification.

Consider batching

When notifications of the same type occur several times in a short period, it is better to group them.

  • Merge notifications of the same type that occur within 5 minutes into one
  • Show a summary such as "3 new comments"
  • Digest notifications collected and sent once a day

This matters especially for incident alerts sent to operators. If one server misbehaves and hundreds of alerts go out, the person on call turns the alerts off. You need a structure that groups alerts with the same cause and, while the situation continues, re-alerts only at set intervals.


2. Each channel has a different character

Even with identical content, reach and cost differ depending on the route.

Channel characteristics

Channel Reach Cost Suitable content
In-app notification inbox Seen only when the user opens the app None Default storage for all notifications
Push Requires app install and permission; can be lost Low Notices that need immediacy
SMS High reach Charged per message Verification, important announcements
Email May be read late Low Detailed content, records
Messengers such as Kakao High reach Charged per message; template review Reservation and payment notices
Phone call / ARS High immediacy High Emergencies

We recommend making the in-app notification inbox the base. Every notification accumulates there, and a subset is pushed out via push or SMS. This way, a user who missed a push can still check later.

Push does not guarantee delivery

These are the constraints to reflect in the design when push is the primary channel. The details below were checked against official documentation as of October 2026.

Item Behavior What to reflect in the design
Notification permission On Android 13 (API 33) and higher, notifications for newly installed apps are off by default, and the app must obtain the POST_NOTIFICATIONS runtime permission Decide when to request the permission, and identify the list of notifications that will not reach users without it
Acceptance vs. delivery The message ID returned by FCM means FCM received the request, not that it reached the device Do not treat a successful send as "confirmed"
Devices off for a long time If an Android device has not connected to FCM for more than a month, FCM accepts the message and then discards it immediately. Tokens inactive for 270 days are expired and sends to them are rejected Store the last refresh time per token and clean up stale tokens
Retention period FCM messages for Android and web are stored for 4 weeks by default (maximum 2,419,200 seconds). APNs tries only once and does not store the notification when apns-expiration is 0 Set a short expiry for time-limited notifications
Priority The Android default is normal, and delivery may be delayed in Doze. High wakes the device, but may be downgraded to normal if a pattern is detected in which messages do not result in user-visible notifications Use high only for types that need immediate attention

(Android Developers: Notification runtime permission, Firebase: Manage FCM registration tokens, Firebase: Set message lifespan, Firebase: Set message priority, Apple Developer: Sending notification requests to APNs)

The Android 13 permission has one more trap. If the app's targetSdk is 32 or lower, the system shows the permission dialog on its own the first time a screen appears after a notification channel has been created. That is usually the moment the app is first launched, so users tend to deny it without knowing why, and in this case, once denied, the system does not ask again until the app is reinstalled or the targetSdk is raised. We recommend targeting SDK 33 or higher and requesting the permission yourself after showing why notifications are needed.

Below is an example FCM HTTP v1 request body for a case such as a reservation reminder, which is time-limited and where a new notification may replace the previous one. The token and values are illustrative.

{
  "message": {
    "token": "DEVICE_REGISTRATION_TOKEN",
    "notification": {
      "title": "You have a reservation tomorrow at 14:00",
      "body": "Changes and cancellations are possible until the end of today"
    },
    "android": {
      "priority": "high",
      "ttl": "3600s",
      "collapse_key": "reservation-1201"
    },
    "apns": {
      "headers": {
        "apns-priority": "10",
        "apns-expiration": "1791878400",
        "apns-collapse-id": "reservation-1201"
      }
    }
  }
}
  • ttl is a duration in seconds and apns-expiration is a UNIX time (in seconds). Because the units differ, setting only one of them correctly is a common mistake. The time value in the example above is calculated and filled in at send time
  • When collapse_key is the same, FCM discards the earlier message that is still pending and undelivered and replaces it with the new one. Notifications with the same apns-collapse-id are merged into one on the device, and the value cannot exceed 64 bytes
  • A collapse key is a means of tidying up the notifications the user sees. The problem of the server sending the same notification twice has to be prevented separately with the deduplication key in Section 3

AlimTalk can only carry informational messages

Kakao AlimTalk (알림톡) can be sent even to users who have not added the channel, but it is limited to informational messages that fall under the "exceptions to advertising information for commercial purposes" defined in the Network Act guide. If all or part of the content is advertising, it cannot be sent, and messages can only be sent using templates approved in Kakao's review. (Kakao Business Guide: AlimTalk, AlimTalk Review Guide)

The part that affects the schedule is the review.

  • The guide states that reviews are processed in order within 2 business days. If a template is rejected, it has to be revised and resubmitted, so set the template finalization date earlier in the launch schedule
  • Variables must be given example text, and having more than 40 variables or a template made up only of variables are grounds for rejection. A design that reuses a single "#{내용}" variable for every message will not pass
  • Because only approved wording goes out, manage AlimTalk templates on a schedule separate from code deployment, and keep the review status together with the template management described in Section 5

Rules when using multiple channels

  • If the same content is sent through several channels at once, the user receives duplicates
  • One approach is to rank channels by priority and cancel later ones once the notification is confirmed on an earlier channel
  • Decide whether to fall back to another channel on failure

Staged delivery of important notifications is useful in practice. A push is sent first, and if it is not confirmed within a set time, an SMS follows. The paid channel is then used only when needed.

What has to be decided when implementing fallback delivery is "when do we move on to the next channel?"

Situation Basis for the decision Handling
No address to send to No valid push token, or notification permission is off Move to the next channel immediately without waiting
Send failed permanently The channel rejected it, for example because the token is invalid Move to the next channel immediately
Sent, but no response No read record within the set time Wait, then move to the next channel
Already confirmed The notification or its target screen was viewed in the app Cancel the send on the next channel

To tell the third and fourth cases apart, the app has to report reads to the server. A successful send response from the push channel alone does not tell you whether the user saw it. If you implement only "SMS when push fails" in a structure without read reporting, nothing reaches the users whose push was silently lost.

As a rule, limit fallback delivery to informational notifications. Promotional notifications can only be sent within the scope of consent obtained per medium, so you must not hand a message over to SMS for a user who consented only to push advertising.

Put caps on paid channels

SMS and messengers incur a cost per message. If the target audience is set incorrectly, it leads to financial loss.

  • A cap on the number of recipients per send
  • A cap on total volume per period
  • Block and alert the person in charge when a cap is exceeded
  • Recipient count check and approval procedure before bulk sends

An approval procedure before bulk sends looks cumbersome but is necessary. Accidents in which a wrong condition causes a send to the entire membership do happen.


3. When to send is half of it

Even with the same content, how it is received changes with timing.

Restrict sending hours

  • Block sends late at night and early in the morning
  • Decide whether notifications generated during restricted hours are sent the next morning or dropped
  • Make urgent types an exception. State explicitly which ones are exceptions

Making the exception list explicit is important. If every notification is classified as urgent, the restriction becomes meaningless.

The night-time restriction on promotional notifications is a statutory requirement, not an operating policy. Article 50(3) of the Network Act requires prior consent, separate from the general consent to receive, in order to send advertising information from 9:00 PM to 8:00 AM the following day, and Article 61(2) of its Enforcement Decree exempts only email. (Network Act, Article 50, Enforcement Decree of the same Act, Article 61) App push is also subject to this provision. The guide interprets these hours as the time the message reaches the recipient, in Korea Standard Time, and explains that when a message arrives at night because of a system delay, the sender has to provide an explanation.

So you have to account for cases where the queue backs up and messages go out late. Check the time once when the send request is created, and once more right before the worker actually sends. Below is an example function that calculates the next allowed send time for a promotional notification without night-time consent (TypeScript, fixed to Korea Standard Time).

const KST_OFFSET_MS = 9 * 60 * 60 * 1000;

function nextAdSendTime(now: Date, hasNightConsent: boolean): Date {
  if (hasNightConsent) return now;

  const kst = new Date(now.getTime() + KST_OFFSET_MS);
  const hour = kst.getUTCHours();
  if (hour >= 8 && hour < 21) return now;

  const next = new Date(kst);
  if (hour >= 21) next.setUTCDate(next.getUTCDate() + 1);
  next.setUTCHours(8, 0, 0, 0);
  return new Date(next.getTime() - KST_OFFSET_MS);
}

The statutory boundaries are 21:00 and 8:00, but since the criterion is arrival time, it is safer in operation to leave a margin on either side. For example, cut off at 20:50 and start from 8:10; set these values after looking at send volume and channel latency.

Distinguish immediate from scheduled sends

  • Immediate — sent at the moment the event occurs
  • Scheduled — sent at a specific time, such as a notice one day before a reservation
  • Recurring — sent at a regular interval, such as a reminder about unprocessed items

There is something to watch with scheduled and recurring sends. The situation may have changed by the time of sending. At the moment the "your reservation is tomorrow" notice scheduled yesterday is sent, that reservation may already have been cancelled. A step that re-checks the conditions right before sending is needed.

Make sure the same notification does not go out twice

Duplicate sends erode user trust quickly. The ways they occur are fairly well defined.

  • A send was retried because it appeared to have failed, when it had actually succeeded
  • A batch ran twice
  • Several places call the send for the same event
  • With multiple servers, each processed the same target

The way to prevent this is to put a unique key on each unit of sending. Build a single key out of "this user, this event, this type of notification," and do not process a key that has already been sent.

What the key consists of depends on the business. A confirmation notification for the same reservation should go out only once, but if the status changes several times, a notification may need to go out each time. The standard for "the same" has to be defined per type.

Type Example key Result
Reservation confirmed reservation:1201:confirmed Once per reservation
Processing status changed order:881:status:SHIPPED Once per status value
Notice one day before reservation reservation:1201:remind:2026-10-14 Once per reservation and date
Reminder about unprocessed items approval:55:nudge:2026-W42 Once per week

Implementing this as "look up whether the key has already been sent, and send if not" does not prevent duplicates. If two workers look up at the same time, both see "not found." You have to put a unique constraint on the key and let only the side that succeeds in inserting the record first do the send. Below is an example send request table for PostgreSQL 10 or later.

CREATE TABLE notification_outbox (
  id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  dedupe_key      text        NOT NULL,
  user_id         bigint      NOT NULL,
  type            text        NOT NULL,
  channel         text        NOT NULL,
  payload         jsonb       NOT NULL,
  status          text        NOT NULL DEFAULT 'PENDING'
                  CHECK (status IN ('PENDING', 'SENDING', 'SENT', 'FAILED', 'CANCELED')),
  attempts        int         NOT NULL DEFAULT 0,
  next_attempt_at timestamptz NOT NULL DEFAULT now(),
  last_error      text,
  created_at      timestamptz NOT NULL DEFAULT now(),
  sent_at         timestamptz,
  CONSTRAINT uq_notification_dedupe UNIQUE (dedupe_key, channel)
);

CREATE INDEX idx_outbox_due ON notification_outbox (next_attempt_at)
  WHERE status IN ('PENDING', 'SENDING');
CREATE INDEX idx_outbox_user ON notification_outbox (user_id, created_at);
INSERT INTO notification_outbox (dedupe_key, user_id, type, channel, payload)
VALUES ('reservation:1201:confirmed', 57, 'RESERVATION_CONFIRMED', 'PUSH',
        '{"reservationId": 1201}')
ON CONFLICT (dedupe_key, channel) DO NOTHING;

Even if a batch runs twice or the same event is called from several places, the second INSERT creates no row. The channel is included in the unique constraint so that the same event can be sent by push and then, as a fallback, by SMS. This table is also used as is for the send queue in Section 5.

The general principles for handling idempotency keys in calls to external systems are covered separately in Where External System Integrations Fail — Principles of Interface Design.

Keep notifications from piling up on one person

When several notifications occur at once, they arrive one after another for a single person.

  • A cap on the number of sends per user per unit of time
  • Batch or delay when the cap is exceeded
  • Assign priorities per type and drop the lowest first

With the table above, the cap check is a single query. This example counts the pushes queued for one user over the last hour.

SELECT count(*)
FROM notification_outbox
WHERE user_id = 57
  AND channel = 'PUSH'
  AND status IN ('PENDING', 'SENDING', 'SENT')
  AND created_at > now() - interval '1 hour';

Whether to drop or defer when the cap is exceeded is decided differently per type. For example: exclude types such as verification codes and payment confirmations from the cap calculation, drop promotional types first, and for progress notifications push next_attempt_at back so they become candidates for batched delivery. This check can be off by one or two between requests that arrive simultaneously. A frequency limit is not a value that has to be exact, so accept that margin of error, and guarantee the things that must go out exactly once with the deduplication key.


Users leave notifications on only when they feel they are in control.

Let users set preferences per type

If you offer only an all-on / all-off switch, users who dislike promotional notifications turn everything off. As a result, important notices do not reach them either.

  • Receive or not, per type
  • Receive or not, per channel
  • Time-of-day settings
  • Pause everything temporarily

Managing preferences as a combination of type and channel is the accurate approach. Settings such as "order notifications by push, no promotions" must be possible.

Distinguish what can be turned off from what cannot

Some notifications, such as mandatory transaction-related notices, cannot be turned off. This distinction has to be shown clearly on the settings screen.

  • Indicate that mandatory notifications cannot be turned off in settings
  • Obtain separate consent for advertising information, and allow withdrawal at any time
  • Record the history of consent and withdrawal

Statutory requirements apply to the transmission of advertising information, so first check whether a message falls under it, and then align the consent method and labeling.

Statutory requirements that apply to advertising notifications

Translated into system features, the requirements set by Article 50 of the Network Act and its Enforcement Decree are as follows. This summary is based on the legislation in force as of October 2, 2026 (Act No. 21988, Presidential Decree No. 36728) and the 7th Revised Edition of the guide by the Broadcasting, Media and Communications Commission and KISA (published December 2025). The guide was written against the Act as it stood before the 2026 amendments, so for parts that changed afterwards, such as the article numbers of the administrative fine provisions, we followed the current text. (Network Act, Article 50, Enforcement Decree, Articles 61 through 62-3, Guide to the Network Act for Preventing Illegal Spam, 7th Revised Edition)

Requirement Basis What the system needs
Explicit prior consent Act, Article 50(1) Obtain consent to receive advertising as an item separate from terms-of-service and personal information consent, and record the time and route of consent
Transaction relationship exception Act, Article 50(1), Subparagraph 1; Enforcement Decree, Article 61(1) The scope that can be sent without consent is limited to 6 months from the date the transaction ended, and to goods or services of the same kind as those transacted. Include the transaction end date in the send conditions
No transmission after refusal or withdrawal Act, Article 50(2) Exclude from send targets immediately upon withdrawal. Re-check items already in the queue right before sending
Separate consent for night-time transmission Act, Article 50(3); Enforcement Decree, Article 61(2) Keep a separate consent item for 21:00 to 8:00 the following day, and hold the send if it is absent (email excluded)
Labeling Act, Article 50(4); Enforcement Decree, Attached Table 6 "(광고)" (Advertisement) and the sender's name and contact information at the beginning, and a free-of-charge method of refusing receipt at the end. Attach these automatically in promotional templates and block the send if they are missing
Notice of processing result Act, Article 50(7); Enforcement Decree, Article 62-2 Within 14 days from the date the expression of consent, refusal, or withdrawal was received, notify the sender's name, the fact and date of the expression of intent, and the processing result
Confirmation of consent to receive Act, Article 50(8); Enforcement Decree, Article 62-3 Every 2 years from the date consent was obtained, inform the recipient of the sender's name, the fact and date of consent, and how to maintain or withdraw it

The sanctions for violations are not light. Violations of Article 50(1) through (4) and (8) are subject to an administrative fine of up to KRW 30 million, and failure to give notice of the processing result under paragraph (7) to an administrative fine of up to KRW 10 million (Act, Article 76(2) and (4)), and the March 2026 amendment added a provision (Act, Article 50-9) under which a penalty surcharge of up to 6/100 of the relevant revenue may be imposed. Amendments in this area are ongoing, so please re-check the current provisions at the time you apply them.

We have picked out a few interpretations from the guide that directly affect design.

  • Name and default value of the consent item — Obtaining consent under a name that does not make clear it is advertising, such as "consent to benefit notifications" or "marketing consent," pre-selecting the checkbox as agreed, or substituting consent to the terms of service is not regarded as explicit prior consent. Name the item "광고성 정보 수신 동의" (consent to receive advertising information)
  • Consent per medium — The guide recommends letting users choose SMS, app push, email, and messenger individually. If consent was obtained per medium, you may send only through the media consented to. This fits the type-and-channel combination structure described earlier as is
  • Refusing app push advertising — The push must lead directly to the opt-out screen. Merely describing the path, as in "turn off in Settings > Notifications," or requiring login to opt out is regarded as making refusal difficult. You need an opt-out screen that opens even when logged out, and a deep link to it
  • Labeling of app push advertising — The "(광고)" label applies to push as well. The sender's name may be omitted only when it is the same as the app name
  • Notice of processing result — When the intent was expressed in the app or on the web, notifying by popup is also accepted. In the notice, write the date the intent was expressed as the actual date rather than "today," and do not mix in advertising content
  • Starting point of the 2-year cycle — Count every 2 years from the date of the initial consent. It is not counted anew from the date the notice was given. If the user withdrew and then consented again, the last consent date is the reference
  • What is not advertising — Information requested by the recipient and information on the performance of a concluded contract (confirmation, changes, safety and security notices, and so on) are not advertising information. By contrast, notices about the issuance or expiry of coupons or mileage granted regardless of the recipient's intent are regarded as advertising information

To meet these requirements, the current state of consent and its history have to be managed separately. Below is a minimal example (PostgreSQL 10 or later).

CREATE TABLE ad_consent (
  user_id            bigint      NOT NULL,
  channel            text        NOT NULL,
  is_agreed          boolean     NOT NULL,
  is_night_agreed    boolean     NOT NULL DEFAULT false,
  agreed_at          timestamptz,
  next_reconfirm_due date,
  PRIMARY KEY (user_id, channel)
);

CREATE TABLE ad_consent_log (
  id                 bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id            bigint      NOT NULL,
  channel            text        NOT NULL,
  action             text        NOT NULL
                     CHECK (action IN ('AGREE', 'WITHDRAW', 'NIGHT_AGREE', 'NIGHT_WITHDRAW')),
  source             text        NOT NULL,
  acted_at           timestamptz NOT NULL DEFAULT now(),
  result_notified_at timestamptz
);

source records the route through which the intent was received, such as the sign-up screen, the settings screen, the push opt-out link, or the 080 opt-out number. This is because the sender has to prove that consent was obtained. next_reconfirm_due is set at the time of consent to the date 2 years after agreed_at, and 2 years are added each time the notice is sent.

The two deadlines are kept by a check job that runs daily.

-- Items whose processing result has not yet been notified (statutory deadline 14 days; the example alerts on day 10)
SELECT id, user_id, channel, action, acted_at
FROM ad_consent_log
WHERE result_notified_at IS NULL
  AND acted_at < now() - interval '10 days';

-- Targets whose deadline for confirming consent to receive falls within 30 days
SELECT user_id, channel, agreed_at, next_reconfirm_due
FROM ad_consent
WHERE is_agreed
  AND next_reconfirm_due <= current_date + 30;

The 10 days and 30 days are illustrative values. The simplest approach is to show the result in a popup and fill in result_notified_at as soon as consent is changed on screen; the first query is for catching items that did not go through that path (080 opt-out, requests received by customer support, and so on).

For matters that turn on legal interpretation, for example whether a particular message constitutes advertising information, please check the cases and Q&A in the guide first, and obtain a legal review if the judgment is difficult.

Choose defaults carefully

Most users do not change the default settings. The default is, in effect, the actual setting.

  • Turning every notification on from the start causes early churn
  • Turning on too few means necessary notices do not reach users either
  • Decide based on the nature of the service, but leave room to adjust during operation

5. Manage what happens after sending

Sending is the start; there are things left to check afterwards.

Separate sending from the main service

If notifications are sent in the same flow as order processing, order processing slows down when the delivery channel slows down. If the send fails, the order may fail as well.

The typical structure looks like this.

① Business processing completes → ② Send request is enqueued → ③ A separate worker performs the actual send

This is what the structure gives you.

  • Delays in the delivery channel do not propagate to the main service
  • Failed items are easy to retry
  • Processing speed can be throttled even when send volume spikes
  • Workers can be split per channel so they do not affect each other

The point to watch is that business processing and enqueueing the send request must succeed together or fail together. If the order is saved but the send request is lost, the notification does not go out, and nobody knows. You need a structure that wraps the two operations in the same transaction.

Enqueue with an outbox and retry

If you use a separate message queue, a gap remains in which "the DB commit succeeded but enqueueing failed." A common way to close this gap is the outbox approach, which puts the send request into a table in the same DB as the business data. The notification_outbox table created in Section 3 is used as is.

BEGIN;

UPDATE reservations
SET status = 'CONFIRMED'
WHERE id = 1201 AND status = 'REQUESTED';

-- Run the INSERT ... ON CONFLICT DO NOTHING statement from Section 3 here

COMMIT;

The worker changes the status as it picks up the rows that are due. Use FOR UPDATE SKIP LOCKED so that multiple workers do not grab the same row (PostgreSQL 9.5 or later).

WITH due AS (
  SELECT id
  FROM notification_outbox
  WHERE status IN ('PENDING', 'SENDING')
    AND next_attempt_at <= now()
  ORDER BY next_attempt_at
  LIMIT 100
  FOR UPDATE SKIP LOCKED
)
UPDATE notification_outbox o
SET status = 'SENDING',
    attempts = o.attempts + 1,
    next_attempt_at = now() + interval '5 minutes'
FROM due
WHERE o.id = due.id
RETURNING o.id, o.user_id, o.type, o.channel, o.payload, o.attempts;

Pushing next_attempt_at 5 minutes ahead at pickup is the key. Even if the worker dies mid-send, the row becomes eligible again 5 minutes later. Without this, rows are left in the SENDING state forever. The 5 minutes and 100 rows are illustrative values; set the interval comfortably longer than the channel call timeout.

Finish the row according to the result.

-- Success
UPDATE notification_outbox
SET status = 'SENT', sent_at = now()
WHERE id = 3310;

-- Transient failure: retry at intervals of 2, 4, 8 minutes... and give up on the 5th attempt
UPDATE notification_outbox
SET status = CASE WHEN attempts >= 5 THEN 'FAILED' ELSE 'PENDING' END,
    next_attempt_at = now() + interval '1 minute' * power(2, attempts),
    last_error = 'UNAVAILABLE'
WHERE id = 3310;

What this structure guarantees is "at least once." If the worker dies after sending to the channel but before recording SENT, the same notification goes out one more time. There are three ways to reduce this.

  • If the channel provides a deduplication mechanism, pass it along. APNs merges notifications with the same apns-collapse-id into one
  • Re-check the conditions right before sending. If the reservation has been cancelled or consent to receive has been withdrawn, end the row as CANCELED
  • Do not send old requests. For progress notifications past a certain time since creation, discarding is better than retrying

The outbox table keeps growing, so periodically move completed rows to a history table or cut them off by partition. When send volume grows beyond what the DB can handle, put a message queue in front of the workers, but if the outbox worker takes on the role of moving items to the queue, the same guarantee can be maintained.

Keep a send history

These are the items to record.

Category Items
Target Recipient, receiving address or token
Content Notification type, content sent, linked target
Route Channel, send time
Result Success or failure, failure reason
Response Whether read, whether clicked

It is important to keep the actual content that was sent. When an inquiry comes in saying "I received such-and-such," it cannot be reproduced if the template has changed since.

Because personal information is included, the retention period and masking rules have to be decided together.

Handle failures

  • Retry transient failures
  • Do not retry permanent failures. Nonexistent addresses and blocked recipients fall here
  • Exclude targets that fail repeatedly from the send list
  • Send an alert when the failure rate spikes

If you do not clean up repeatedly failing targets, cost and reputation deteriorate together. If you keep sending to nonexistent addresses, the delivery channel itself may be sanctioned.

Transient and permanent failures are distinguished by the response the channel returns. These are the criteria for the two push channels.

Channel Response Meaning Handling
FCM 404 UNREGISTERED The app instance was unregistered Delete the token, do not retry
FCM 400 INVALID_ARGUMENT Invalid request value. Cases of a bad token and a bad body are mixed together Do not retry. Delete the token only when the body is certain to be valid
FCM 429 QUOTA_EXCEEDED Send quota exceeded Wait for the retry-after header duration, then retry. 60 seconds if the header is absent
FCM 500 INTERNAL, 503 UNAVAILABLE Transient server-side error Retry with exponential backoff
APNs 410 Unregistered The token is no longer valid Delete the token
APNs 400 BadDeviceToken The token is invalid or the environment (development vs. production) does not match Do not retry. Check the environment configuration first
APNs 429 TooManyRequests Too many consecutive requests to the same device token Retry after an interval
APNs 500, 503 Server-side error or shutting down Retry

(Firebase: FCM error codes, Firebase: Best practices when sending FCM messages at scale, Apple Developer: Handling notification responses from APNs)

Firebase advises not retrying 400, 401, 403, and 404; when retrying, waiting at least 10 seconds and then using exponential backoff with jitter; and discarding requests that are no longer timely rather than retrying them indefinitely. Translated into code, this classification looks like the following (TypeScript).

type SendOutcome = 'RETRY' | 'DROP_TOKEN' | 'FAIL';

function classifyFcmError(httpStatus: number, errorCode: string): SendOutcome {
  if (errorCode === 'UNREGISTERED') return 'DROP_TOKEN';
  if (httpStatus === 429 || httpStatus >= 500) return 'RETRY';
  return 'FAIL';
}

Not classifying INVALID_ARGUMENT as DROP_TOKEN is intentional. It prevents the accident in which, on a day when a wrong template variable breaks the body, every perfectly good token gets deleted.

Token cleanup does not rely on failure responses alone. Firebase recommends recording a timestamp when storing a token on the server, having the app refresh it periodically, and then treating tokens that have not been refreshed for a certain period as stale and cleaning them up. The threshold FCM uses by default is one month, but set the period to match how often your service is used. Applying a 30-day threshold to an app used once a month deletes the tokens of normal users. Also, because one user may use several devices, or different accounts may log in on one device, store tokens one-to-many against users and detach the device's token from the account on logout. If you do not detach it, the previous user's notifications are shown to the next user.

Common errors and causes

Symptom Common cause Fix
The same notification arrives twice Duplicates are checked in look-up-then-send order. Or the worker restarted after a successful send but before recording it Claim with a unique constraint. Pass the channel's collapse key as well
Send succeeded but the user did not receive it Notification permission off, a device that has not connected for a long time, device power-saving state Do not interpret success as delivery. Complement with the in-app notification inbox and read reporting
Only iOS fails entirely right after going to production Development environment tokens were sent to production APNs (BadDeviceToken) Store the environment with the token and send to the endpoint for each environment
Failure rate spikes from a particular date The APNs certificate or authentication key expired or is missing (FCM 401 THIRD_PARTY_AUTH_ERROR, APNs 403) Register the expiry date in the operations calendar, alert immediately on authentication error responses
A notice for a cancelled reservation was sent Scheduled sends are not re-validated right before sending The worker checks the source status before sending and marks the row CANCELED
Promotional notifications arrive at night Processed after 21:00 because of queue delay Re-check the time right before sending, leave a margin on the cutoff time
Sent to a user who opted out The user withdrew between target extraction and sending Even for bulk sends, check consent status per item right before sending
Sent as "Hello, !" with the name missing Missing template variable Do not send when variable validation fails; mark as FAILED
Sends back up on the hour Scheduled sends are concentrated at the top of each hour Spread out send times. Firebase recommends avoiding the 2 minutes before and after minutes 0, 15, 30, and 45 of each hour

Measure the effect

You cannot judge by send counts alone.

  • Delivery rate — the share actually delivered
  • Read rate — the share the user viewed
  • Action rate — the share that led to an actual action through the notification
  • Opt-out rate — the share who turned the setting off after receiving this notification

The last metric is the most important. A notification with a high opt-out rate is a notification that should not be sent. Even with a high read rate, if the opt-out rate is high as well, it is a loss in the long run.

Manage templates

If notification wording is written directly in code, every change requires a deployment.

  • Separate the wording into templates
  • Define the variable parts
  • If you support multiple languages, manage them per language
  • Keep a change history
  • For channels that require an approval procedure, manage that status as well

Missing variables are a common accident. A value that was supposed to go into the template is empty, and the message goes out as "Hello, !" with no name. This can be prevented with a step that checks all variables are filled in before sending.


6. Wording is half of a notification

Even when delivery works technically, users do not act if the content is unclear.

Put only what is needed into a small space

Push limits the number of characters shown on screen. Long text gets cut off.

  • Put the key point at the front. The end may be cut off
  • Write what happened first, and put elaboration after
  • Do not repeat the service name at the start of the text. It is already shown as the sender
Bad:  [○○ Service] Hello, dear customer. Regarding the request you submitted...
Good: Your reservation is confirmed · March 12, 14:00

Promotional notifications are an exception. As seen in Section 4, "(광고)" and the sender's name must be shown at the beginning, so write the wording within the characters that remain after this label.

Tell the user what to do

If the notification requires action, state the action.

  • "2 items need your approval" rather than "Awaiting approval"
  • If there is a deadline, show it as well
  • Make tapping it go straight to that screen

Do not expose personal information on the lock screen

Push content can be displayed on the lock screen. That means the person next to the user can see it.

  • Do not put information such as treatment details, amounts, or the other party's name in the body
  • Show only the minimum, such as "You have a new message," and have the user check the details in the app
  • For sensitive types, consider whether to make hidden previews the default

There are cases where this decision is missed and it becomes an incident. It is safer to decide per type, at the design stage, whether lock-screen exposure is allowed.

If you support multiple languages

  • Follow the language setting of each user. If there is none, use the device language
  • Manage templates per language
  • Sentence length varies greatly by language, so check truncation for each
  • Match date, time, and currency formats to the language as well

7. Operator alerts follow different criteria

Their purpose differs from user notifications, so the design differs too.

Set criteria for what to alert on

  • Alert immediately only for situations that need immediate action
  • Collect and report things that only need to be acknowledged
  • Alert on metrics only when they cross a threshold

Alerting on every error ends up the same as alerting on nothing. Once the people in charge start ignoring alerts, they miss the signals that really matter.

Put decision-making information in the alert

The recipient must be able to act right away.

  • What has been a problem, and since when
  • Scope of impact — everything or a part
  • Current and usual values of the relevant metrics
  • The screen or log location to check

An alert that says only "An error occurred" forces the recipient to investigate from scratch.

Make the delivery path redundant

If the alerting system itself goes down, no alerts arrive. And you cannot know that either.

  • Have a primary path and a secondary path
  • Send test alerts regularly to confirm the path is alive
  • Decide who is on call at night and on holidays, and the contact order

8. Frequently asked questions

How much effort does a notification feature take?

Looking only at "sending a push" it is small, but the real scope is broad. These are the elements to include in the estimate.

  • The number of notification types and the trigger condition for each
  • The number of channels to integrate
  • The notification settings screen and the logic behind it
  • Send history storage and the screen for viewing it
  • Template management
  • Failure handling and retries
  • A sending tool for operators

The number of types determines the effort. Three notification types and thirty are completely different jobs. If the requirements say only "notification feature," the list of types has to be fixed first.

Does it have to be sent in real time?

In most cases, no. A delay of a few seconds to a few minutes is generally acceptable.

Cases that need immediacy are limited: verification codes, payment confirmations, emergencies, and the like. For the rest, processing through a queue is more stable. Even if the delivery channel slows down, the main service is not affected.

Can notifications be handed to an external service?

Using a service that handles the sending itself is common. These are the points to review.

  • Review of recipient information being transferred outside
  • A fallback path in case of an outage
  • Whether to keep a send history on our side as well
  • Cost structure and caps

It is better to keep a send history on our side as well. If you change the external service, its records are hard to bring over.

What if a notification has to be sent but there is no contact information?

This problem arises in services that do not require contact information at sign-up.

  • The in-app notification inbox works with just an account, so make it the default path
  • For notifications that need an external channel, request contact information at that point
  • Keep a list of which notifications do not reach users without contact information

For work that requires notice, such as payments or contracts, it is better to design the flow so that contact information is collected before the feature is used. Otherwise, when a "I did not receive the notification" problem arises later, there is no basis for responding.

How do we clean up when there are already too many notifications?

Approach it in order.

  1. List every notification type currently being sent
  2. Check send volume, read rate, and opt-out rate per type
  3. Consider stopping those with a high opt-out rate and a low read rate first
  4. Group similar types
  5. Make notification settings more granular so users can choose

The listing step often comes as a surprise. As features were added, notifications were attached one by one, and it has reached a state where nobody has the whole picture.


9. Pre-kickoff checklist

Defining what to send

  • Is the list of notification types finalized?
  • Is the trigger condition for each type clear?
  • Have notifications that do not need to be sent been filtered out?
  • Have the targets for batching been decided?

Channels

  • Is the channel to use decided for each type?
  • Does every notification remain in the in-app notification inbox?
  • Is there a fallback path when a channel fails?
  • Do paid channels have send caps?
  • Is there an approval procedure before bulk sends?
  • Have the timing of the push permission request and the fallback path for users without the permission been decided?
  • Is the AlimTalk template review period reflected in the schedule?
  • Are the conditions for moving to fallback delivery (no address, permanent failure, unread) defined?

Timing

  • Are restricted sending hours and exception types decided?
  • Are conditions re-checked right before a scheduled send?
  • Is there a cap on send frequency per user?
  • Is a deduplication key defined per type and enforced with a unique constraint?

Notification settings

  • Can users set preferences per type and channel?
  • Are notifications that cannot be turned off distinguished and shown as such?
  • Is the consent and withdrawal history for advertising information recorded?
  • Have the defaults been reviewed?

Advertising notifications

  • Is consent to receive advertising separated from consent to the terms, and unchecked by default?
  • Is consent for night-time (21:00 to 8:00) transmission obtained separately, and is the send held if it is absent?
  • Are "(광고)", the sender's name, and the free-of-charge opt-out method attached automatically by the template?
  • Does the push lead directly to the opt-out screen without login?
  • Is the processing result of consent, refusal, or withdrawal notified within 14 days?
  • Is the 2-year-cycle notice confirming consent to receive automated?
  • Is promotional copy blocked from being mixed into informational templates?

Operations

  • Does the send history keep the actual content?
  • Are business processing and enqueueing the send request in the same transaction?
  • Are items picked up again when a worker dies midway?
  • Is there a retry policy per failure type?
  • Are repeatedly failing targets and stale push tokens cleaned up?
  • Is the opt-out rate tracked as a metric?
  • Are templates separated from code?
  • Are missing variables checked before sending?

Operator alerts

  • Has what to alert on immediately been separated from what to collect and report?
  • Are alerts with the same cause grouped?
  • Does the alert contain the information needed to make a decision?
  • Is the alert path checked regularly to confirm it is alive?

Closing

The central question in notification design is not "how do we send it?" but "does this really have to be sent?"

If you attach a notification to every situation where one could be sent, it looks considerate at first, but in the end all of them are ignored. Once that happens, there is no means left to use at the moment you truly need one.

Divide the types, filter out what does not need to be sent, let users choose, and watch the opt-out rate. These four things are how notifications are kept as a living channel.

We have built notification systems of different kinds. In the ad reward app, push based on Firebase Cloud Messaging is operated from the admin console, with send history recorded by target and source, and in B-Saver, a building energy saving solution, we built a notification center in which notification types and channels (email, push, SMS, web) are configured per user and send history is stored per channel. If you are designing a notification feature, we would be glad to work through it with you from the stage of organizing the list of types. Feel free to contact us.


References