In-app notifications, the bell icon, the unread badge, the dropdown of “someone mentioned you”, are one of those features that seem trivial until you build one. Then you discover the unread count drifts out of sync, the real-time updates fight with the page state, and users are furious because there’s no way to turn off the noise. It’s a small feature with a surprising number of sharp edges.
Here’s how to build one that stays accurate, feels instant, and doesn’t drive users away.
The data model
Start with a single, boring table:
notifications (
id, user_id, type, payload JSON,
read_at, created_at
)
A few decisions baked in here:
type+payload, not a rendered string. Storetype = "mention"and a payload of IDs, then render the message at display time. Storing pre-rendered text means every copy change or i18n effort requires a data migration.read_atas a timestamp, not a boolean. A nullable timestamp tells you when it was read, which you’ll want for analytics, and “unread” is simplyread_at IS NULL.- Per-user rows. If a notification fans out to many users, write a row per recipient. It’s more rows but far simpler queries and per-user read state.
A prompt to get the foundation right:
Design an in-app notification system. Schema: notifications with user_id, type, JSON payload, read_at, created_at. APIs: list (paginated), unread count, mark-one-read, mark-all-read. Render messages from type+payload at display time, not stored strings. Show me the schema and API shapes before implementing, and flag anything that won’t scale past a few million rows.
Unread counts that don’t lie
The unread badge is the part users notice when it’s wrong. Two rules keep it honest:
- Derive it, don’t cache it carelessly.
COUNT(*) WHERE user_id = ? AND read_at IS NULLis the source of truth. If you cache the number for performance, invalidate the cache on every read/create, or it will drift. - Use optimistic updates on the client. When a user opens a notification, decrement the badge immediately in the UI, then confirm with the server. Waiting for a round-trip makes the app feel laggy; a badge that updates instantly feels alive. Reconcile if the server disagrees.
Real-time, or good-enough-time
You have three delivery options, in increasing order of effort:
- Poll the unread count every 30–60 seconds. Trivial to build, fine for most products, and honestly what a lot of successful apps do.
- Server-Sent Events push new notifications down a one-way stream. Cheap, real-time, and enough for a notification feed.
- WebSockets if you already have a bidirectional channel for other features.
Don’t over-engineer this. Polling is a perfectly respectable v1, and you can upgrade to SSE later without changing the data model. Ship the simple version, learn whether anyone even wants real-time, then invest.
Give users control, or they’ll leave
The fastest way to make notifications a liability is to make them un-silenceable. Before you add a second notification type, add preferences:
- Per-type toggles (mentions on, marketing off).
- A global “pause all” with a duration.
- Sensible defaults, high-signal types on, low-signal types off.
Notifications are a trust relationship. Every unwanted ping spends trust; every relevant one earns it. Preferences are how users keep that balance where they want it.
Launch it gradually, not with a bang
Here’s the failure mode nobody anticipates: you build the notification system, backfill it against existing activity, flip it on, and thousands of users simultaneously get a bell icon showing “47 unread” for things that happened weeks ago. That’s not a launch; that’s a mass annoyance event, and some of those users will click “disable everything” forever.
A calmer rollout:
- Ship it behind a flag, off for everyone. The plumbing runs in production, writing notification rows, without any user seeing the UI.
- Enable it for your team. Confirm counts are accurate, real-time works, and, critically, that backfilled history doesn’t dump a wall of stale notifications on people.
- Turn it on for a small percentage of users. Watch for the two things load tests won’t catch: the unread-count query’s impact on your database under real traffic, and whether users engage with notifications or immediately mute them.
- Ramp to everyone once counts hold steady and the query cost is acceptable.
Gating the feature also gives you a clean kill switch for the specific way notifications go wrong at scale: a runaway event that generates a notification storm. If some background job starts fanning out ten thousand rows per second, you flip the feature off, fix the job, and turn it back on, instead of frantically deleting rows while users watch their badges spin.
In-app notifications are deceptively deep. Model them for change, keep the counts honest, respect the user’s attention, and roll them out in a way where the first mistake affects a handful of people instead of your entire base.
ShipSilently makes gating a new feature, or hitting the kill switch during a notification storm, a sub-millisecond edge lookup. Try it free.