The context

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

If Andrea wanted to know whether his leave request had been approved, he had to navigate to the leave section. For messages, open chat. For triggered reminders, dig through the global log trying to find his entries among everyone else's. There was no unified collection point.

The idea was simple: a 🔔 bell icon where every operator can see a personal timeline of everything that concerns them — like a LOG but filtered to the current user, browsable even weeks later. No extra push notifications, just a structured archive always available.

ℹ️

Stack: Firebase Realtime Database (chat, calendar, reminders), Firestore (activityLog for leave/permissions), vanilla JavaScript. The panel is a single-page PWA with no framework.

Architecture: six categories, two databases

The first decision was where to read data for each category. The panel already uses both Firebase Realtime Database and Firestore, and the two technologies have different characteristics that make each one a better fit for certain kinds of data.

Why Firestore for leave and permissions

Leave and permission requests already live in Firestore's activityLog collection, where each document records the operator, event type, timestamp, and status. These are historical records that don't change in real time: a leave request approved yesterday won't be modified again. Using .get() (single read) instead of .onSnapshot() (real-time listener) lets you load the history once when the panel opens, keeping the read cost very low.

The query has three conditions: operator equals current user, type matches one of the relevant event types, and timestamp is after the 90-day cutoff. This combination requires a composite index in Firestore — the browser console shows a direct link to create it automatically the first time the query runs.

javascript — Firestore leave query (single read)
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() })));

Why RTDB for calendar, reminders, and chat

Calendar, reminders, and chat live on the Realtime Database, which is the right call for these three categories for the opposite reasons: the nodes are small and the structure is already optimized for the fast reads the panel needs elsewhere. Attaching a .on('value') listener on already-existing nodes adds no significant cost.

For the calendar, the read filters events that have a firedAt field (the appointment has triggered) and where the creator matches the current user or the event's department matches the user's department. Other departments' appointments don't appear.

For reminders, the reminders/{opKey} node contains only the operator's personal reminders. Only entries with fired: true are shown — meaning already triggered, not ones still in the queue. The notification center is a history, not a to-do list.

For chat, DMs are read from chat/dm/{myKey} with limitToLast(100), filtering out messages sent by the current user. For group chats, the listener only attaches to the departments the user belongs to, reading the last 60 messages per group.

💡

Rule of thumb: use Firestore .get() for historical data that doesn't change after it's written (event logs, archived requests). Use RTDB .on('value') for structurally small nodes where reactivity makes sense (chat, reminder status). The read cost shows up immediately in the Firebase console if you get this distinction wrong.

Data source summary

Category Database Node / Query Mode
🏖️ LeaveFirestoreactivityLog (type ferie_*).get() — single
🔵 PermissionsFirestoreactivityLog (type permesso/malattia).get() — single
📅 CalendarRTDBcalendario/.on('value')
⏰ RemindersRTDBreminders/{opKey}.on('value')
💬 DM ChatRTDBchat/dm/{myKey}.on('child_added')
💬 Group ChatRTDBchat/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 dark overlay. The decision to place it as a direct sibling of #mainArea in the DOM — rather than a child of an intermediate container — is intentional: any ancestor with transform or overflow:hidden breaks position:fixed in its children. Appending the panel directly to the body solves the problem for good.

Filters on two rows

The category bar at the top of the panel has six filterable chips: All, Chat, Calendar, Reminders, Leave, Permissions. The first version used overflow-x: auto to allow horizontal scrolling on mobile — but chips would vanish off-screen with no indication that the user could scroll to find them.

The fix was switching the container from overflow-x: auto to flex-wrap: wrap: the chips automatically wrap onto two rows (roughly three per row) adapting to the panel width, with no hidden scroll. A one-line change, but the usability difference is substantial.

"Unread" badge and per-operator localStorage

The numeric badge on the bell counts items the operator hasn't opened yet. The read/unread state needs to persist across sessions — if I close and reopen the panel, previously seen notifications shouldn't show up as new again — but it also needs to be separate for each user on the same machine.

The solution uses localStorage with the key nc_read_{userKey}: each operator has their own set of already-read IDs. When an item is clicked, its ID is added to the set and saved. When the badge is computed, it counts items whose key isn't in the current user's set.

javascript — per-operator read state
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);
}

The bootstrap problem: three fallback levels

The notification center worked perfectly in testing — open the panel, log in, the bell populates. But in production, several operators who entered via "remember me" (session restore) found the panel empty.

The root cause was the same one I'd already hit with the calendar: the _restoreLoginSession() flow wasn't calling _initNotifCenter(). But this time there was an extra wrinkle: _initNotifCenter is defined inside the notification center's JS block, which runs after the core boots. There's no guarantee it exists at the exact moment the session restore completes.

The solution: three cascading fallback levels

  • 1
    Wrapper on _initFCM

    On manual login, the core calls _initFCM(userName). A wrapper intercepts this call and also triggers _initNotifCenter. Covers 100% of manual logins.

  • 2
    Polling every 500ms × 40 attempts

    An interval starts on page load, checks every 500ms whether state.currentUser is set and the notification center isn't already initialized. Covers session restore regardless of when the core sets the user.

  • 3
    MutationObserver on #navUserChip

    An observer watches the visibility of the username chip in the nav. When it becomes visible, triggers init if it hasn't run yet. Also handles logout: when the chip disappears (display:none), calls _destroyNotifCenter(), which detaches all Firebase listeners and resets state.

javascript — polling bootstrap for session restore
(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);
})();
⚠️

Idempotency is non-negotiable: with multiple startup paths (manual login, polling, MutationObserver), _initNotifCenter must be idempotent. The window._ncInitialized flag blocks execution if the notification center is already running, preventing duplicate Firebase listeners.

Positioning the bell bubble: a bumpy ride

The bell UI took more iterations than expected — not because it was technically complex, but because every placement that looked right on desktop caused conflicts on mobile.

The iterations

Version one put the bell in the desktop sidebar as a menu item. Worked on desktop, vanished with the sidebar on mobile. Version two used a position:fixed floating bubble in the bottom-right — same pattern as the existing Chat and AI buttons. Worked, but visually it looked like a fourth floating button crowding an area that already had two.

The final version moves the bubble to the top-right (top:18px; right:18px), away from the bottom chat/AI area, with z-index 999992 to sit above the mobile topbar. The structural pattern mirrors #chatWrapper and #aiWrapper: a div with position:fixed containing the button and its hover label — so the badge stays position:absolute relative to the button, not the viewport.

The conflict with the topbar on mobile

With top:18px, on iPhones and tablets under 1024px wide the bell landed exactly on top of the user chip and hamburger button. The topbar is roughly 56-60px tall (including safe area). The fix in the max-width:1023px media query moves the bubble to top:66px, pushing it below the topbar without touching anything in the desktop layout.

css — bubble positioning and mobile override
/* 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;
  }
}

When a custom multi-category notification center makes sense (and when it doesn't)

When to use it

  • App with several distinct event types to notify (e.g. six different categories): a unified notification center with per-category badges gives an overview that isolated push notifications don't.
  • A browsable history of past notifications is needed, not just a momentary popup: a drawer with persistent history solves the "I saw the notification but don't remember what it said" problem.
  • Already on Firebase with multiple databases (RTDB + Firestore): a two-database architecture separating live state from persistent history leverages each service's different strengths.

When not to use it

  • A single notification type: for a simple use case, one notification system with no categories is enough and easier to maintain.
  • No need for history: if notifications are transient by nature (e.g. instant confirmations), a lighter system with no persistence fits better.

Alternatives

For a simpler case with a single notification channel, a standard push notification service (Firebase Cloud Messaging with no dedicated center) is enough without the complexity of a multi-category drawer.

Common mistakes

  • Not handling fallback levels in initial bootstrap: if the first data source isn't available on first load, an explicit fallback path is needed, otherwise the user sees an empty notification center even when real notifications exist.
  • Underestimating responsive positioning for fixed elements like a badge: a notification bubble positioned with fixed values easily misaligns across breakpoints — it needs testing across multiple screen sizes, not just desktop.

Takeaways

  1. Firestore .get() for historical data, RTDB .on() for reactive nodes. Not everything needs to be real-time. Leave requests approved a week ago aren't changing: a single Firestore read is far cheaper than a permanent open listener. Save the Realtime Database for data that changes while the user is actively watching the screen.
  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." Any function triggered on manual login needs to also fire on restore — and if you can't modify the core, polling with an idempotency flag is a solid fallback.
  3. Three bootstrap levels isn't overkill. In a system where initialization timing is non-deterministic (restore + scripts loading in parallel), having a direct wrapper, a polling fallback, and a MutationObserver as a last safety net means the notification center starts correctly in all practical cases, with no side effects if two levels fire near-simultaneously.
  4. flex-wrap: wrap beats overflow-x: auto for mobile filter chips. Horizontal scroll on a narrow panel is invisible — users don't know they can scroll. If you have 6 chips and the panel is 320px wide, two rows of three always beats hidden horizontal scroll.
  5. position:fixed bubbles on mobile PWAs compete with the topbar. Every fixed element at the top needs a separate media query for mobile that pushes it below the topbar height. Testing on desktop isn't enough: the hamburger and user chip occupy exactly the area where you want to put a notification badge.

Frequently asked questions

Why use both Firestore and the Realtime Database in the same notification center?

Firestore for historical data like already-approved leave and permission requests, read once with .get() since they no longer change — an always-open listener would be needlessly expensive. RTDB for nodes that change while the user is looking at the screen: calendar, reminders, chat, where a real-time .on() listener is needed.

Why does the notification center have three different startup mechanisms (direct wrapper, polling, MutationObserver)?

Initialization timing isn't deterministic: session restore and script loading happen in parallel, so there's no single guaranteed moment when the user becomes available. The three layers — a direct call on login, 500ms fallback polling, and a MutationObserver as a last safety net — cover all practical cases without side effects thanks to an idempotency flag that blocks duplicate initialization.

Why was the notification bell badge moved multiple times before the final version?

Every position that worked on desktop created conflicts on mobile: in the sidebar it disappeared along with it, in the bottom-right it looked like a fourth floating button. The final version places it top-right with a dedicated media query that moves it below the topbar on screens under 1024px, avoiding overlap with the user chip and hamburger menu.

← Back to Blog