What I Filtered Before Sending Push Notifications

Subscription settings, quiet hours, freshness, and audit records choose what not to send first

Push is not finished by receiving an APNs token and sending immediately. It needs user intent, places of interest, notification type, quiet hours, source data freshness, duplicate prevention, and delivery audit records.

Hangangjari notifications were not built to send more messages. They were built to reduce failure during a Hangang outing. So the core question for push is less “what should we send?” and more “what should we not send?”

Push is more sensitive than a screen. A screen is opened by the user, but push interrupts the user’s time first. The hard part was therefore less APNs integration and more the rules that decide whether sending is acceptable. The same source-data change can be useful information to one user and noise to another.

Records needed before sending push

On the server, records before and after delivery are separated like this.

AreaRole
IdentityStores installation-level signals
SubscriptionTracks APNs device token and subscription state
PreferenceUser intent by park, parking lot, and notification type
Notification factNormalized candidate facts from source data
Fact claimPrevents the same fact from being processed twice
Delivery decisionRecords why something was sent or blocked
OutboxDurable queue waiting for actual push delivery
Delivery attemptRecords delivery responses and failures
Redis indexSupporting index for fast candidate lookup

Postgres keeps reference and audit records. Redis helps candidate lookup and stream speed.

Path from candidate to delivery

sequenceDiagram
  autonumber
  participant iOS as iOS app
  participant API as Push API
  participant PG as Postgres
  participant Redis as Preference index
  participant Facts as Notification facts
  participant Worker as Push worker
  participant APNs as APNs

  iOS->>API: Register subscription and notification settings
  API->>PG: Store installation / subscription / preferences
  API->>Redis: Refresh fast candidate lookup index
  Facts->>PG: Store normalized notification candidates
  Worker->>PG: Claim candidates to process
  Worker->>Redis: Find target subscriptions quickly
  Worker->>PG: Evaluate delivery policy and record decision
  Worker->>PG: Add sendable items to outbox
  Worker->>APNs: Send to APNs
  APNs-->>Worker: Return delivery result
  Worker->>PG: Record attempt result and follow-up action

The important record here is the delivery decision. A notification candidate does not automatically enter the outbox. Only candidates that pass the sendable conditions enter the delivery queue. The rest remain with suppression reasons.

The system must first look at which notifications the user wanted. The delivery queue and audit matter after that boundary exists. Filtering notifications the user did not want comes first; APNs delivery comes next.

flowchart LR
  Fact["Notification fact"] --> Claim["Fact claim"]
  Claim --> Candidates["Candidate lookup<br/>Redis first<br/>Postgres fallback"]
  Candidates --> Policy["Delivery policy<br/>preferences<br/>quiet hours<br/>cooldown<br/>freshness"]
  Policy --> Decision{"Delivery decision"}
  Decision -->|push_now| Outbox["Push outbox"]
  Decision -->|suppressed| Suppressed["Suppressed decision"]
  Outbox --> APNs["APNs"]
  APNs --> Attempts["Delivery attempts"]

Rules for not sending came first

If every change becomes a notification, the app becomes tiring quickly. A simple congestion change may mean nothing to some users. But a sharp drop in a favorite parking lot or a control notice for a watched park can change behavior.

So internal fact types are not exposed directly to users. They are grouped into notification units users understand.

  • Important changes.
  • Parking failure prevention.
  • Events and notices for watched parks.
  • Quiet hours.
  • Repeated-notification cooldown.
  • Delivery hold based on source data freshness.

Source-data freshness needed particular caution in push. Facts from stale data should not be pushed immediately. Notifications that change user behavior have to be treated more conservatively.

APNs delivery is the last step

When first building iOS push, it is easy to focus on APNs device-token registration. From the server’s perspective, that value is only the start.

Once the service runs, these problems are harder:

  • Device tokens can refresh or become invalid.
  • The same installation can move through several subscription states.
  • Users can turn permission off and on.
  • Delivery failure can be temporary or permanent.
  • The same notification candidate can be sent twice after a worker restart.

That is why outbox and delivery attempts are separated. The outbox is “work to send.” An attempt is “a record that we tried to send.” APNs responses update both audit records and device-token state.

The thing to preserve was not just “was it sent?” but “why did the system do that?” Whether a device token was invalid, quiet hours suppressed delivery, source data was stale, or APNs failed temporarily changes the next action. Without storing that difference, push quality can only be judged by feeling.

Permission is requested after the reason is understood

It is easy to show the notification permission prompt on first launch. Hangangjari chose to ask after the user understood the need.

The user should first choose a watched park or parking lot and understand what notification is useful. Permission is a system popup, but persuasion comes from the app’s wording and sequence.

The notification settings UI also should not expose internal tables directly. The unit a user understands is not notification_fact_type; it is whether something important happened to the place they plan to visit.

Not sending became quality

APNs device-token registration was only the beginning of the push system. User intent and server sending rules had to be separated, and outbox plus delivery attempts had to remain durable so operators could choose the next action.

When source-data freshness is low, not sending can be the right choice. Fast lookup indexes are separate, while final reasons and audit records remain in storage that can explain them later. The permission request timing also follows the user’s understanding, not implementation convenience.

Push quality is decided less at the moment the technology connects and more at the moment the system decides not to send. Because push interrupts the user’s time first, good push in Hangangjari means push whose reasons can be explained, not push sent in large volume.

Share

Share

Image preview