Skip to main content
ANVISoftware Solutions
Lesson 8 of 11Advanced19 min

Push Notifications

By the end of this lesson

Deliver timely notifications without being intrusive.

A push notification is a short message your server asks a platform service to deliver to one device. Your code is not running when it arrives. The operating system receives it, decides whether to show it, and draws it — on the lock screen, in a banner, in a list the user may not look at for hours.

Three parties are involved and you control one of them. Your server sends to the platform's push service, the push service delivers to the device, and the device's operating system decides what the user sees. Every hop can delay, deduplicate or drop the message. So delivery is best-effort: not guaranteed, not instant, and not ordered. A notification is a prompt to look at your app, never the mechanism that carries a fact your app depends on.

For the field app that distinction is concrete. If a visit is reassigned, the server records it and the app fetches the change when it next syncs. The notification exists so an engineer hears about it before driving to the wrong site. If the notification never arrives, the app is still correct — the engineer finds out later than you hoped, which is a worse experience rather than a broken one.

Five terms used precisely for the rest of this lesson:

Push service
The platform-operated relay between your server and the device. Each platform runs its own, each has its own registration and its own rules, and neither lets you reach a device directly. It holds a message for a while when a device is offline, and eventually discards it.
Device token
An identifier the push service issues for one installation of your app on one device. Your server stores it so it knows where to send. It changes — on reinstall, on restore to a new device, and sometimes for no reason you can see — so treat it as something to refresh and to prune, not a stable key.
Payload
The small message you send. Part of it is text the system displays; part is data your app reads when the user opens the notification. It is measured in a couple of kilobytes, so it carries a pointer to the real information rather than the information itself.
Notification permission
The user's explicit agreement to be sent notifications. Both platforms require it, both ask once through a system prompt you cannot restyle or repeat, and a decline can only be reversed by the user in device settings. Assume nobody will go there.
Deep link
The route the notification opens, resolved to a screen stack as covered in the navigation lesson. The notification named something specific, so the app has to land on that specific thing.
A payload that names a destination and carries nothing confidential
JSON
{
  "display": {
    "title": "Visit reassigned",
    "body": "Your 14:00 visit has moved to Northgate Depot"
  },
  "routing": {
    "type": "visit.reassigned",
    "path": "/employees/482/notes",
    "visitId": "9f31"
  },
  "replaces": "visit.9f31",
  "urgency": "normal",
  "sentAt": 1712050200
}
  • These field names are invented for this lesson. Every push service uses its own, and none of them look exactly like this. The shape is the transferable part: text for the system to display, separate data for your app to act on, and a small amount of metadata about the message itself.
  • Splitting display from routing keeps the two audiences apart. A person reads the first; your code reads the second. Mixing them produces a body string that some screen has to parse, which breaks the first time someone edits the wording.
  • The path is a deep link, and it arrives from outside your app. Validate it on the way in, exactly as the navigation lesson's resolver does, and resolve it to a stack rather than a single screen so back has somewhere to go.
  • Nothing in this payload is confidential. A depot name and a time are visible on a lock screen to anybody holding the device, which is the correct standard for a notification. Had the message needed to mention a medical detail, a disciplinary matter or a salary, the body would say that there is something to read and the app would show it after the user is past the lock screen.
  • The replaces key asks the system to supersede an earlier notification about the same visit instead of stacking a new one beside it. A device that has been out of signal for three hours can otherwise deliver nine notifications at once, of which eight are already wrong.
  • Urgency is a request, not an instruction. The normal class lets the platform batch delivery to save battery, which can mean minutes of delay. Both platforms offer a more immediate class and both limit how much of it you may use, so reserve it for what genuinely cannot wait and expect to justify it.
  • sentAt lets the app tell how old a message is. Because delivery is best-effort, a notification tapped at 16:00 may have been sent at 11:00, and a screen that renders it as current is misleading.

Permission is the part most teams get wrong, and it is worth understanding as a budget rather than a checkbox. You get one system prompt. If the user declines it, you do not get another; the setting moves into a device settings screen almost nobody visits. And anyone who later finds your notifications irrelevant turns them off there, permanently, without telling you. So the budget is spent once and it can only shrink.

That argues for asking at a moment when the value is obvious, not on first launch. On first launch the user has seen one screen and has no idea what you would send them, so the honest answer to the prompt is no. Ask instead at the point where the notification is clearly useful: the first time a visit is assigned to the engineer by someone else. Show your own short explanation first — one sentence about what you will send and roughly how often — and request the system prompt only after they agree to it. A decline on your own screen costs you nothing, because the system prompt is still unspent and you can ask again next week.

After permission, relevance and timing are what keep it. Every notification spends a little of the user's patience, and an irrelevant one spends a lot. A notification at 03:00 about a visit that starts at 14:00 costs you the permission, and along with it every future notification that would have mattered. Decide per notification type whether it can wait for working hours, and hold the ones that can. The server is the right place for that decision, because it knows the recipient's shift pattern and the device does not. Batch what is routine, send immediately only what changes what the user is about to do, and give people per-category control inside your app so they can decline one kind rather than all of them.

Then land them in the right place. A notification about visit 9f31 that opens the home screen has made the user do the work its own wording described, and the second time that happens they stop tapping. Deep-link to the screen the notification is about, with a sensible parent beneath it, and load fresh data on arrival — the payload told you what changed, not what the current state is.

Choosing the moment to ask, and remembering a soft decline
TypeScript
type PermissionState = "notAsked" | "granted" | "denied";

interface AskContext {
  permission: PermissionState;
  /** True once a visit has been assigned to this engineer by someone else. */
  hasAssignedVisit: boolean;
  /** Set when the user declined our own explanation screen. */
  deferredUntil: number | null;
  softDeclines: number;
}

const DEFER_MS = 1000 * 60 * 60 * 24 * 7;
const MAX_SOFT_DECLINES = 2;

/**
 * Whether to show our own explanation. The system prompt is requested only
 * after the user agrees here, because the system prompt is one-time.
 */
export function shouldExplainNotifications(ctx: AskContext, now: number): boolean {
  if (ctx.permission !== "notAsked") return false;
  if (!ctx.hasAssignedVisit) return false;
  if (ctx.softDeclines >= MAX_SOFT_DECLINES) return false;
  if (ctx.deferredUntil !== null && now < ctx.deferredUntil) return false;
  return true;
}

export function afterSoftDecline(ctx: AskContext, now: number): AskContext {
  return { ...ctx, softDeclines: ctx.softDeclines + 1, deferredUntil: now + DEFER_MS };
}
  • As everywhere in this course, the shape is the transferable part. Requesting permission and reading its current state are platform calls that look nothing like this, and the toolkit you use will have its own names for them.
  • The two-step ask is the whole design. Your own screen is repeatable and costs nothing when refused. The system prompt is one-time and a refusal there is effectively permanent, so it is requested only when the user has already said yes to the idea.
  • hasAssignedVisit is the condition that makes the value obvious, written as a rule rather than a hope. It means the prompt cannot appear on first launch, because on first launch nothing has been assigned. Putting that in code is what stops the requirement quietly eroding when someone adds an onboarding screen later.
  • deferredUntil separates not now from no. Someone who declines your explanation while standing in the rain has not made a decision about notifications, and a week later is a reasonable time to mention it again.
  • MAX_SOFT_DECLINES stops that becoming nagging. Two refusals of your own screen is an answer, and continuing to ask is the behaviour that makes people uninstall an app. The trade-off is real: a lower limit respects the user more and loses some people who would have said yes on a better day.
  • The function is pure, so the rule is testable without a device. Assert that a fresh install is never asked, that a deferred user is quiet for a week, and that a granted user is never shown the screen again.

Summary

  • A push travels from your server through a platform service to the operating system, so delivery is best-effort — never build a feature that depends on one arriving
  • Permission is a one-time budget that can only shrink, so ask at a moment when the value is obvious and show your own repeatable explanation before the system prompt
  • Relevance and timing are what keep the permission: hold on the server anything that can wait for working hours, and let users switch off one category rather than all of them
  • Deep-link to the screen the notification is about, resolved to a full stack, and load current data on arrival
  • Treat the visible payload as public, because it is drawn on a lock screen — send a pointer to sensitive content, never the content

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Pick the moment to ask

The field app wants permission to notify engineers when a visit is reassigned or cancelled.

Write down three candidate moments to request the system prompt, and rank them. For each, say what the user knows about your notifications at that point.

Then decide what the app does for someone who declined the system prompt eight months ago and now asks why they never hear about cancellations.

Show solution

A reasonable ranking. Best: immediately after the first visit is assigned to the engineer by someone else, because the value is concrete and the user has just seen the exact event you would notify them about. Middle: at the end of a first shift, when they have used the app and can judge whether hearing from it would help. Worst: on first launch, when they know nothing and the safe answer is no.

The point of ranking them is that acceptance tracks how much the user knows, not how well the prompt is worded. No amount of copywriting fixes a prompt that arrives before the user understands what it is for.

For the person who declined eight months ago, you cannot ask again. The system prompt is spent. What you can do is explain, inside the app, that notifications are switched off for it and point them at the device settings screen where that is changed, with a plain description of what they would start receiving. Make the link text describe the destination rather than saying to tap here, so it makes sense read on its own.

There is a lesson in how weak that recovery is. It is the strongest argument for spending the one prompt carefully, and it is why the moment is an engineering decision rather than a detail for whoever builds the onboarding screens.

Challenge

Design two notifications and their quiet-hours behaviour

Two events in the field app: a visit is cancelled, and an occupational health note has been added to an engineer's record for them to acknowledge.

For each one, write the payload you would send, including exactly what the display text says. Then decide what happens when the event occurs at 02:40 and the engineer's shift starts at 07:00, and say where that decision is made.

Show solution

The cancellation can be specific, because the facts are not confidential to anyone holding the device: a title naming a cancelled visit, and a body with the time and the site. Routing carries the visit identifier and a path to the visit screen. A replaces key keyed to the visit stops a later change stacking a second, contradictory notification beside it.

The health note cannot be specific. Even naming it as occupational health on a lock screen discloses something about that person to whoever is looking at the phone. So the display text says that there is a message to read in the app and nothing more, and the routing carries the identifier the app uses to load it after the device is unlocked. This is the case where a vague notification is the correct engineering choice rather than a lazy one.

At 02:40, neither is worth waking somebody. The cancellation matters before they set off, so hold it and deliver near the start of the shift — the engineer needs it by 07:00, not at 02:40. The health note can wait for working hours by the same reasoning. The cost of sending both immediately is not one bad night; it is the permission, and with it every future notification.

That decision belongs on the server. It knows the shift pattern, the recipient's timezone and whether a later event has already superseded this one. The device knows none of that reliably, and a device-side hold cannot work at all when the app is not running, which is most of the time.

One honest caveat worth stating in your design: holding a message means it is not delivered when it was sent, so the payload needs sentAt and the screen needs to show when the change happened. A held notification that reads as current is a different kind of wrong.

Saved in this browser only.