DEV Community

Andrea Roversi
Andrea Roversi

Posted on • Originally published at roversia.it

Building a Notification Center in a Firebase PWA — Firestore vs RTDB and Three Bootstrap Fallback Levels

The control panel I use to coordinate a sales team already had FCM push notifications, DM and department group chats, timed reminders with sound, leave/permission requests, and a shared calendar. Each system worked fine on its own — but they were separate islands.

If an operator wanted to know whether their leave request had been approved, they navigated to the leave section. For messages, they opened chat. For triggered reminders, they dug through a global log looking for their own entries among everyone else's. No unified place to check.

The idea: a 🔔 bell icon showing a personal timeline of everything relevant to the current user — a log filtered to them, browsable weeks later. No extra push, just a structured, always-available archive.

Stack: Firebase Realtime Database (chat, calendar, reminders), Firestore (activityLog for leave/permissions), vanilla JavaScript. Single-page PWA, no framework.

Architecture: six categories, two databases

The panel already uses both RTDB and Firestore, and each is a better fit for different kinds of data.

Why Firestore for leave and permissions

Leave and permission requests already live in Firestore's activityLog collection — historical records that don't change after the fact. Using .get() (single read) instead of .onSnapshot() (real-time listener) loads history once on panel open, keeping read cost low.

The query has three conditions — operator, event type, and a 90-day cutoff — which requires a composite index. Firestore's console shows a direct link to create it automatically the first time the query runs.

const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000;
const tipi = [
  'ferie_richiesta', 'ferie_approvata',
  'ferie_rifiutata', 'ferie_annullata'
];

db.collection('activityLog')
  .where('operatore', '==', currentUser)
  .where('tipo', 'in', tipi)
  .where('ts', '>=', cutoff)
  .get()   // single read, not real-time
  .then(snap => snap.docs.map(d => ({ id: d.id, ...d.data() })));
Enter fullscreen mode Exit fullscreen mode

Why RTDB for calendar, reminders, and chat

Calendar, reminders, and chat live on RTDB, whose small nodes and existing structure are already optimized for fast reads. Attaching a .on('value') listener to an existing node adds no significant cost.

  • Calendar — filters events with a firedAt field where the creator matches the current user or the department matches theirs.
  • Remindersreminders/{opKey} holds only that operator's personal reminders; only entries with fired: true show (it's a history, not a to-do list).
  • Chat — DMs from chat/dm/{myKey} with limitToLast(100), filtering out self-sent messages. Groups only attach for the departments the user belongs to, last 60 messages each.

Rule of thumb: Firestore .get() for historical data that doesn't change after being written; RTDB .on('value') for structurally small, reactive nodes. Getting this backwards shows up immediately in the Firebase console bill.

Category Database Node / Query Mode
🏖️ Leave Firestore activityLog (type ferie_*) .get() — single
🔵 Permissions Firestore activityLog (type permesso/malattia) .get() — single
📅 Calendar RTDB calendario/ .on('value')
⏰ Reminders RTDB reminders/{opKey} .on('value')
💬 DM Chat RTDB chat/dm/{myKey} .on('child_added')
💬 Group Chat RTDB chat/groups/{gk} .on('child_added')

The UI: drawer, categories, and badge

The panel as a "portal"

The notification center is a slide-in panel from the right with a semi-transparent overlay. It's placed as a direct sibling of #mainArea in the DOM rather than nested inside an intermediate container — deliberately, since any ancestor with transform or overflow:hidden breaks position:fixed in its children. Appending directly to document.body solves it for good.

Filters on two rows

The category bar has six filterable chips: All, Chat, Calendar, Reminders, Leave, Permissions. The first version used overflow-x: auto for horizontal mobile scrolling — chips would vanish off-screen with no indication you could scroll for more. Switching to flex-wrap: wrap made chips wrap onto two rows automatically. A one-line fix with a real usability difference.

"Unread" badge and per-operator localStorage

The bell's numeric badge counts unopened items. Read state must persist across sessions but also stay separate per user on the same machine. Solution: localStorage keyed as nc_read_{userKey}, each operator with their own set of already-read IDs.

function _ncGetRead(userKey) {
  try {
    return new Set(JSON.parse(localStorage.getItem('nc_read_' + userKey) || '[]'));
  } catch(e) { return new Set(); }
}

function _ncMarkRead(userKey, itemId) {
  const set = _ncGetRead(userKey);
  set.add(itemId);
  localStorage.setItem('nc_read_' + userKey, JSON.stringify([...set]));
}

function _ncUpdateBadge() {
  const read = _ncGetRead(state.currentUser);
  const unread = _ncItems.filter(item => !read.has(item.id)).length;
  const badge = document.getElementById('notifBadge');
  if (badge) badge.textContent = unread > 99 ? '99+' : String(unread);
}
Enter fullscreen mode Exit fullscreen mode

The bootstrap problem: three fallback levels

The notification center worked perfectly in testing. In production, several operators who came in via "remember me" found the panel empty.

Same root cause I'd already hit with the calendar: _restoreLoginSession() wasn't calling _initNotifCenter(). But this time with an extra wrinkle — _initNotifCenter is defined inside the notification center's own JS block, which loads after the core boots, so there's no guarantee it even exists at the exact moment session restore completes.

Three cascading fallback levels:

  1. Wrapper on _initFCM — on manual login, the core calls _initFCM(userName). A wrapper intercepts it and also fires _initNotifCenter. Covers 100% of manual logins.
  2. Polling every 500ms × 40 attempts — checks whether state.currentUser is set and the center isn't already initialized. Covers session restore regardless of timing.
  3. MutationObserver on #navUserChip — watches the username chip's visibility; triggers init when it appears, and calls _destroyNotifCenter() on logout (chip hidden) to detach all listeners and reset state.
(function _ncBootstrap() {
  var attempts = 0, maxAttempts = 40;
  var iv = setInterval(function() {
    attempts++;
    if (attempts > maxAttempts) { clearInterval(iv); return; }

    if (window.state && state.currentUser && !window._ncInitialized) {
      clearInterval(iv);
      // User is already logged in (session restore): start immediately
      _initNotifCenter(state.currentUser);
    }
  }, 500);
})();
Enter fullscreen mode Exit fullscreen mode

Idempotency is non-negotiable here: with three separate startup paths, _initNotifCenter must check window._ncInitialized to block duplicate Firebase listeners if two levels fire near-simultaneously.

Positioning the bell bubble: a bumpy ride

The bell UI took more iterations than expected — not for technical complexity, but because every placement that looked right on desktop broke on mobile.

Version one put it in the desktop sidebar — vanished on mobile along with the sidebar. Version two used a position:fixed floating bubble bottom-right, same pattern as existing Chat and AI buttons — worked, but looked like a fourth floating button crowding an already busy corner.

The final version moves it top-right (top:18px; right:18px), mirroring the structural pattern of #chatWrapper and #aiWrapper. On screens under 1024px it lands exactly on the topbar's user chip and hamburger button, so a media query pushes it to top:66px on mobile.

/* Desktop: top-right corner */
#notifWrapper {
  position: fixed;
  top: 18px;
  right: 18px;
  z-index: 999992;
}

/* Mobile/tablet: drop below the topbar */
@media (max-width: 1023px) {
  #notifWrapper {
    top: 66px;   /* 60px topbar + 6px margin */
    right: 14px;
  }
}
Enter fullscreen mode Exit fullscreen mode

Takeaways

  1. Firestore .get() for historical data, RTDB .on() for reactive nodes. Not everything needs to be real-time — a single Firestore read is far cheaper than a permanent open listener for data that doesn't change.
  2. Session restore is a separate code path. As a developer you almost always use manual login; as a PWA user you almost always use "remember me." Anything triggered on login needs to also fire on restore.
  3. Three bootstrap levels isn't overkill when initialization timing is non-deterministic. A direct wrapper, a polling fallback, and a MutationObserver safety net, with an idempotency flag, cover all practical cases.
  4. flex-wrap: wrap beats overflow-x: auto for mobile filter chips. Hidden horizontal scroll is invisible to users; two wrapped rows aren't.
  5. position:fixed bubbles on mobile PWAs compete with the topbar. Every fixed top element needs its own mobile media query.

Full write-up on my blog: roversia.it/blog-04-centro-notifiche-pwa.html

Top comments (0)