DEV Community

Cover image for Building a Notification System That Doesn't Annoy Users
Anoop Kumar
Anoop Kumar

Posted on

Building a Notification System That Doesn't Annoy Users

The first version of TokenPulse's notification system was simple: fire a notification when usage crossed 75%, 90%, or 100%. Users turned it off within a day.

The problem was obvious in retrospect — the notification fired every time the condition was checked, not just when the threshold was first crossed. If you were at 82% and the extension polled every 5 minutes, you got a "you're at 75%" notification every 5 minutes indefinitely.

Here is the three-iteration journey to a notification system users actually keep enabled.

What a good threshold notification system needs

Before writing code, define the behavior precisely:

  1. Fire once when usage first crosses a threshold
  2. Do not fire again for the same threshold in the same window
  3. Reset when usage drops below the threshold
  4. Support multiple thresholds independently
  5. Persist state across service worker restarts Most notification systems get 1 and 2 right but fail on 3 and 5.

Version 1 — fires every poll (wrong)

// Polled every 5 minutes
function checkNotifications(utilization) {
  if (utilization >= 0.75) {
    chrome.notifications.create({
      type: 'basic',
      iconUrl: chrome.runtime.getURL('icons/icon48.png'),
      title: 'TokenPulse',
      message: `You are at ${Math.round(utilization * 100)}% of your limit`,
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Problem: fires on every poll cycle once above threshold. User gets a notification every 5 minutes.

Version 2 — tracks last notified (better, still wrong)

let lastNotifiedThreshold = 0 // In-memory — dies with service worker

function checkNotifications(utilization) {
  const thresholds = [75, 90, 100]
  const crossed = thresholds.filter(t => utilization * 100 >= t).pop() || 0

  if (crossed > lastNotifiedThreshold) {
    lastNotifiedThreshold = crossed
    fireNotification(crossed, utilization)
  }
}
Enter fullscreen mode Exit fullscreen mode

Problem: lastNotifiedThreshold is in memory. When Chrome kills and restarts the service worker, it resets to 0. The user gets duplicate notifications after every service worker restart.

Also: never resets when usage drops. Once you hit 90%, you never get a 75% notification again even after the window resets.

Version 3 — persisted, threshold-aware, resetting (correct)

// service-worker.js

const THRESHOLDS = [50, 75, 90, 100]

async function shouldNotify(stateKey, currentPct, settings) {
  // Filter to enabled thresholds
  const enabled = THRESHOLDS.filter(t => {
    if (t === 50)  return settings.notify_50  !== false
    if (t === 75)  return settings.notify_75  !== false
    if (t === 90)  return settings.notify_90  !== false
    if (t === 100) return settings.notify_100 !== false
    return false
  })

  if (enabled.length === 0) return null

  // Find the highest threshold crossed
  const crossed = enabled.filter(t => currentPct >= t).pop() || 0

  // Read persisted state
  const stored = await chrome.storage.local.get('lastNotified')
  const lastNotified = stored.lastNotified || {}
  const last = lastNotified[stateKey] || 0

  // Reset: if usage has dropped below the last notified threshold
  if (crossed === 0 && last > 0) {
    lastNotified[stateKey] = 0
    await chrome.storage.local.set({ lastNotified })
    return null
  }

  // No new threshold crossed
  if (crossed <= 0 || crossed <= last) return null

  // New threshold crossed — persist and notify
  lastNotified[stateKey] = crossed
  await chrome.storage.local.set({ lastNotified })
  return crossed
}
Enter fullscreen mode Exit fullscreen mode

The key insight: stateKey is a string that identifies which window and platform we are tracking — for example 'claude_5hour' or 'claude_7day'. This allows independent threshold tracking for each limit.

Firing the notification

async function checkRateLimitNotifications(usage) {
  const settings = await Storage.getSettings()

  // Check 5-hour limit
  const sessionPct = Math.round((usage.five_hour?.utilization || 0) * 100)
  const sessionThreshold = await shouldNotify(
    'claude_5hour',
    sessionPct,
    settings
  )

  if (sessionThreshold) {
    const messages = {
      50:  'Halfway through your 5-hour session limit.',
      75:  'Only 25% of your session remaining.',
      90:  'Almost out — consider wrapping up your session.',
      100: 'Session limit reached. Claude will be restricted.',
    }

    chrome.notifications.create(`tp_session_${sessionThreshold}`, {
      type: 'basic',
      iconUrl: chrome.runtime.getURL('icons/icon48.png'),
      title: `Claude session at ${sessionThreshold}%`,
      message: messages[sessionThreshold],
      priority: sessionThreshold >= 90 ? 2 : 1,
    })
  }

  // Check 7-day limit (same pattern, different key)
  const weeklyPct = Math.round((usage.seven_day?.utilization || 0) * 100)
  const weeklyThreshold = await shouldNotify(
    'claude_7day',
    weeklyPct,
    settings
  )

  if (weeklyThreshold) {
    chrome.notifications.create(`tp_weekly_${weeklyThreshold}`, {
      type: 'basic',
      iconUrl: chrome.runtime.getURL('icons/icon48.png'),
      title: `Claude weekly limit at ${weeklyThreshold}%`,
      message: `Your 7-day usage is at ${weeklyThreshold}%.`,
      priority: weeklyThreshold >= 90 ? 2 : 1,
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

The reset mechanism explained

The reset logic is the part most implementations get wrong.

When does a threshold reset? When the usage drops below the threshold level. If you were notified at 90% and your session resets, usage drops to 0%. The next check sees crossed === 0 and last === 90, so it clears lastNotified[stateKey] to 0.

On the next poll at, say, 30% usage, crossed === 0 still, so nothing fires. When usage climbs back to 76%, crossed === 75 and last === 0, so the 75% notification fires again.

This is exactly the right behavior: you get warned at each threshold once per window, and the warnings reset with the window.

Giving notifications meaningful content

Generic percentage notifications are less useful than contextual ones:

function buildNotificationMessage(threshold, limitType, utilization) {
  const pct = Math.round(utilization * 100)

  // Estimate time remaining based on burn rate
  // (simplified — real implementation tracks usage over time)
  const timeHints = {
    50: 'You probably have enough messages for another 2 hours at your current pace.',
    75: 'Consider wrapping up long sessions — roughly 30-45 minutes of usage left.',
    90: 'Almost out. Finish your current task before the limit hits.',
    100: 'Limit reached. Start a fresh session after the reset.',
  }

  return timeHints[threshold] || `Usage at ${pct}%.`
}
Enter fullscreen mode Exit fullscreen mode

"You probably have enough messages for another 45 minutes" is more actionable than "You are at 75%." Users make better decisions with context.

User controls

Notifications the user cannot control get disabled. Add per-threshold toggles in settings:

// Default settings
const DEFAULT_SETTINGS = {
  notify_50:  false, // Off by default — too early for most users
  notify_75:  true,
  notify_90:  true,
  notify_100: true,
  notify_response_ready: true,
}
Enter fullscreen mode Exit fullscreen mode

50% off by default reduces notification fatigue for new users. They can enable it if they want earlier warnings.

The response-ready notification

One notification that users love and is easy to miss: "your response is ready."

When Claude is generating a long response, developers often switch tabs. The response finishes but they don't notice for several minutes. A notification when generation completes brings them back:

// content-script.js
function watchForResponseCompletion() {
  const observer = new MutationObserver(() => {
    const isGenerating = document.querySelector('[data-generating="true"]')
    const wasGenerating = window._tpWasGenerating

    if (wasGenerating && !isGenerating) {
      chrome.runtime.sendMessage({ type: 'RESPONSE_READY' })
    }

    window._tpWasGenerating = !!isGenerating
  })

  observer.observe(document.body, { childList: true, subtree: true })
}
Enter fullscreen mode Exit fullscreen mode
// service-worker.js
if (msg.type === 'RESPONSE_READY') {
  const settings = await Storage.getSettings()
  if (settings.notify_response_ready === false) return false

  chrome.notifications.create('tp_response_ready', {
    type: 'basic',
    iconUrl: chrome.runtime.getURL('icons/icon48.png'),
    title: 'Response ready',
    message: 'Your Claude response has finished generating.',
    priority: 1,
  })
  return false
}
Enter fullscreen mode Exit fullscreen mode

Full implementation at github.com/anu-ship-it/TokenPulse.
TokenPulse — free AI usage tracker for Claude, ChatGPT, Gemini, DeepSeek and Grok.

Top comments (0)