In-app announcements: talking to users after the build shipped
6 min read
The day after the store launch, a fact about the architecture became a product problem: there was no way to tell users anything. Over-the-air updates aren't installed, so nothing reaches a running app; the store's "What's New" only reaches people who read store pages; and push notifications hadn't shipped yet. A bug found on launch day would have been invisible to every installed copy until the next review cycle.
In-app announcements are the channel that fixes that — a global model the admin console writes and the app reads. The feature is mostly small decisions, and two of them are the kind you only find with a real device in your hand.
TL;DR — A global
Announcementmodel (nouserId, admin-write, everyone-read). Severity picks the shape (INFO = dismissible strip, IMPORTANT = dialog shown once, CRITICAL = full-screen takeover); kind only picks the wording. Targeting is by the version users are running, compared segment by segment — a string compare sorts "1.10.0" below "1.9.0" and would mis-target every release after the ninth.actionUrl: "store"is a sentinel the app resolves per platform. The force-update wall fails open by construction: any error, timeout or empty cache renders nothing, so offline is never a wall. Seen state loads inside the component, never at root; a new account is never walked through the backlog. And from device testing: messages must arrive live (a subscription feeding the query cache), and a modal that must appear unprompted cannot be a bottom sheet.
(Part 35 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)
Severity picks the shape; kind picks the words
Two enums, deliberately orthogonal. Severity decides how a message interrupts:
-
INFO→ a dismissible strip on Home. "What's new in 1.0.3." -
IMPORTANT→ a centred dialog, shown once. "Your receipts from Tuesday were re-processed." -
CRITICAL→ a full-screen takeover. "This version can't sync — please update."
Kind (WHATS_NEW / UPDATE_AVAILABLE / NOTICE) only drives the icon and label. Keeping them separate means an update nudge can be an INFO strip or a CRITICAL wall depending on how bad the old build is — the admin chooses the interruption level independently of the message type. Every message also lands in a What's-new archive screen, badged while unread, so a dismissed strip isn't gone forever.
Target the version people are running
The reader has to already be installed for a message to arrive, so targeting is by app version: inclusive minVersion/maxVersion on the row. The bug-in-waiting was the comparison. A string compare sorts "1.10.0" below "1.9.0" — perfectly, silently wrong, and it would have mis-targeted every release after the ninth. Versions are compared segment by segment as numbers, in one small module the whole app shares.
The action link got a sentinel: actionUrl: "store". The admin never pastes a store URL — the app resolves the sentinel to the right listing per platform, so a message can't carry an App Store link to Android users or vice versa. Any other actionUrl deep-links into the app.
The force-update wall fails open — by construction
A CRITICAL announcement can be marked blocking: a wall the user can't dismiss, for "this version corrupts data" emergencies. The design principle is that the wall must be impossible to raise by accident:
- Any fetch error, timeout or empty cache renders nothing. An offline user is never walled; a backend hiccup is never a lockout. The wall only appears when the app has positively received a blocking row.
- Only
CRITICALmay block; a blocking row must carry anactionUrl(a wall with no exit is a hostage situation); the composer demands a typed confirmation before publishing one. -
active: falseis a one-click kill switch that releases everyone. - A blocking row ignores the seen-list — acknowledging a wall must not walk through it.
"Fails open" is the whole security posture, inverted: for a feature whose failure mode is locking out your own users, the safe default is to do nothing.
Seen state: per user, loaded in the right place
Dismissals are local state (announcements don't roam — you dismiss on each device), keyed per user and held in the preferences store so the strip, the dialog and the archive can't disagree about what's been seen. Where it's loaded matters: inside the <Announcements /> component, never at the root layout — the root runs before the per-user storage setup from Part 27 has keyed storage to the signed-in account, and would read the previous user's seen-list.
Two more gates: <Announcements /> mounts inside the onboarding gate's children, so a message can never interrupt currency onboarding. And an account-age gate: non-blocking rows published before the user's profile was created never interrupt or badge — a brand-new user (or an old account on a new device, since seen state doesn't roam) must not be walked through six weeks of release notes on first open. Blocking rows are exempt, because a force-update wall must stop a fresh install too.
What device testing found
They didn't arrive on their own. The query refetched on mount, foreground and reconnect — sensible for most data, and exactly the wrong shape for a message published "now": an open app learned nothing until the user backgrounded it. The fix was a live subscription (observeQuery) holding one AppSync connection and pushing the filtered list straight into the React Query cache — so every consumer updates together, the persisted cache still serves offline, and a failed socket degrades to what it did before. No schema change: read permission already covers listen.
The IMPORTANT sheet could not be made to work. It began as a bottom sheet, which opened at handle height on Android and not at all on iOS. The cause was structural: a bottom sheet needs an imperative present() call and a measured container — and fired from an effect at app start, it had neither. Fixing the content measurement just moved the problem to the container. Meanwhile INFO and CRITICAL never missed, because both render declaratively from state. So the sheet was rebuilt as a centred <Modal> dialog: visible is a boolean, height is its content, nothing to measure and nothing to time. The rule worth keeping: a surface that must appear unprompted should not depend on a measurement.
Small conventions that rode along
- The body is a JSON field in the same shape as the legal pages (
{paragraphs, bullets}) — a JSON string on write, string-or-parsed on read (the Part 14 lesson, still paying). - Time filtering happens in the query function, not a memo — the React Compiler rejects clock reads there — so a scheduled row appears on the next refetch rather than to the second. Acceptable; a scheduled announcement is not a countdown.
- Archive cards key on the row id, never on their text: release-note bullets repeat across versions.
What I took away
- Ship the reader before you need it. An announcement system reaches only builds that already contain it; the first version to include it protects every version after.
- Compare versions as segments. "1.10.0" < "1.9.0" as strings is a bug you ship on your tenth release.
- Force-update must fail open. The only safe default for a feature that can lock out users is silence.
- Orthogonal enums: interruption level and message type are different decisions.
- Load per-user state where per-user storage is ready — not at the root.
- Unprompted surfaces render from state, never from an imperative call that needs a measurement.
Next up
Part 36: the activity log — what users did, for support and usage questions, with a rule that keeps user text out of it entirely, one row per action rather than per write, and the repo's first secondary indexes.
If your app has no OTA updates and no push, how would you tell every installed copy about a bug tomorrow?
0 reactions · 0 comments
Discuss on dev.to