In today's fast-paced web applications, real-time notifications have become essential for providing users with immediate updates and enhancing their overall experience. In this comprehensive guide, I'll walk you through creating a real-time notification system using Vue 3's Composition API, Laravel Echo, and Pusher.

The implementation we'll cover includes a complete notification system with features like:

  • Connection management with automatic reconnection
  • Persistent subscriptions that survive page refreshes
  • Notification state management
  • Elegant UI components for displaying notifications
  • Browser notifications support

Table of Contents

Prerequisites

Before we start building, you'll need:

  • A Vue 3 project using Composition API
  • A Laravel backend with Laravel Reverb and Laravel Echo Server set up
  • Basic knowledge of WebSockets and event broadcasting

Once you have all of the above, we can start digging in 😎👌🔥

Understanding the Architecture

Our notification system follows a layered architecture:

  • Laravel Echo Client: Core WebSocket connection manager
  • Notification Service: Business logic for handling notifications
  • Notification Store: State management for notifications
  • UI Components: Visual presentation of notifications

Each layer has a specific responsibility, making the system maintainable and extensible.

Setting Up Laravel Echo

Let's start by creating a dedicated Echo service that manages the WebSocket connections. This service will handle connection state persistence, automatic reconnection, and channel subscriptions.

// echo.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

// LocalStorage keys for persistence
const ECHO_CONNECTION_ID = 'user_echo_connection_id';
const ECHO_CONNECTION_STATE = 'user_echo_connection_state';
const ECHO_SUBSCRIBED_CHANNELS = 'user_echo_subscribed_channels';
const ECHO_CHANNEL_HANDLERS = 'user_echo_channel_handlers';
const ECHO_LAST_ACTIVE = 'user_echo_last_active';

// Singleton instance
let echoInstance = null;

const createEcho = () => {
  const token = localStorage.getItem('token');
  const lastActive = localStorage.getItem(ECHO_LAST_ACTIVE);
  const timeElapsed = lastActive ? Date.now() - parseInt(lastActive) : null;

  // Update last active time
  localStorage.setItem(ECHO_LAST_ACTIVE, Date.now().toString());

  // Reuse existing connection if active
  if (echoInstance && 
      echoInstance.connector && 
      echoInstance.connector.pusher && 
      echoInstance.connector.pusher.connection.state === 'connected') {
    console.log('Reusing existing Echo instance with active connection');
    return echoInstance;
  }

  // Create new Echo instance
  echoInstance = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_REVERB_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT,
    forceTLS: import.meta.env.MODE === 'production' ? import.meta.env.VITE_REVERB_SCHEME : false,
    disableStats: true,
    enabledTransports: ['ws', 'wss'],
    cluster: 'mt1', //we are using a dummy cluster
    activityTimeout: 30000,
    pongTimeout: 15000,
    enableLogging: true,

    // Required for private channels
    authEndpoint: 'https://api.example.com/broadcasting/auth',  

    auth: {
      headers: {
        'X-APP-AUTH': import.meta.env.VITE_AUTH_KEY,
        'Authorization': `Bearer ${token}`,
        'Accept': 'application/json'
      }
    }
  });

  // Connection event handlers
  echoInstance.connector.pusher.connection.bind('connected', () => {
    const socketId = echoInstance.connector.pusher.connection.socket_id;
    localStorage.setItem(ECHO_CONNECTION_ID, socketId);
    localStorage.setItem(ECHO_CONNECTION_STATE, 'connected');
    localStorage.setItem(ECHO_LAST_ACTIVE, Date.now().toString());

    // Automatic resubscription to saved channels
    resubscribeToSavedChannels();
  });

  // Add more connection event handlers...
  echoInstance.connector.pusher.connection.bind('disconnected', () => {
    localStorage.setItem(ECHO_CONNECTION_STATE, 'disconnected');
  });

  echoInstance.connector.pusher.connection.bind('error', (err) => {
    console.error('Pusher connection error:', err);
  });

  // Track subscriptions for persistence
  echoInstance.saveSubscription = (channelName, eventName, handlerInfo) => {
    try {
      // Get current subscriptions
      const subscribedChannels = JSON.parse(localStorage.getItem(ECHO_SUBSCRIBED_CHANNELS) || '[]');
      if (!subscribedChannels.includes(channelName)) {
        subscribedChannels.push(channelName);
        localStorage.setItem(ECHO_SUBSCRIBED_CHANNELS, JSON.stringify(subscribedChannels));
      }

      // Save event handlers
      const handlers = JSON.parse(localStorage.getItem(ECHO_CHANNEL_HANDLERS) || '{}');
      if (!handlers[channelName]) {
        handlers[channelName] = [];
      }

      // Check for duplicate handlers
      const existingHandler = handlers[channelName].find(h => h.event === eventName);
      if (!existingHandler) {
        handlers[channelName].push({
          event: eventName,
          type: handlerInfo.type
        });
        localStorage.setItem(ECHO_CHANNEL_HANDLERS, JSON.stringify(handlers));
      }
    } catch (error) {
      console.error('Error saving subscription:', error);
    }
  };

  // Handle visibility changes to reconnect when tab becomes active
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
      const pusher = echoInstance?.connector?.pusher;
      if (pusher && pusher.connection.state !== 'connected') {
        console.log('Page visible, reconnecting...');
        pusher.connect();
      }
    }
  });

  return echoInstance;
};

// Function to resubscribe to saved channels
function resubscribeToSavedChannels() {
  try {
    const subscribedChannels = JSON.parse(localStorage.getItem(ECHO_SUBSCRIBED_CHANNELS) || '[]');
    const handlers = JSON.parse(localStorage.getItem(ECHO_CHANNEL_HANDLERS) || '{}');

    if (subscribedChannels.length === 0) return;

    // Trigger resubscription via global handler
    if (window.userResubscribeToChannels) {
      window.userResubscribeToChannels(subscribedChannels, handlers);
    }
  } catch (error) {
    console.error('Error during resubscription:', error);
  }
}

// Clear Echo state on logout
createEcho.clearEchoState = () => {
  localStorage.removeItem(ECHO_SUBSCRIBED_CHANNELS);
  localStorage.removeItem(ECHO_CHANNEL_HANDLERS);
  localStorage.removeItem(ECHO_CONNECTION_ID);
  localStorage.removeItem(ECHO_CONNECTION_STATE); 
  localStorage.removeItem(ECHO_LAST_ACTIVE);

  // Disconnect instance if exists
  if (echoInstance && echoInstance.connector) {
    try {
      echoInstance.connector.pusher.disconnect();
    } catch (e) {
      console.error('Error disconnecting Echo:', e);
    }
    echoInstance = null;
  }
};

// For debugging
window.userEchoDebug = {
  getInstance: () => echoInstance,
  getConnectionState: () => echoInstance?.connector?.pusher?.connection?.state || 'no-instance',
  clearState: createEcho.clearEchoState,
  getStoredState: () => ({
    connectionId: localStorage.getItem(ECHO_CONNECTION_ID),
    connectionState: localStorage.getItem(ECHO_CONNECTION_STATE),
    channels: JSON.parse(localStorage.getItem(ECHO_SUBSCRIBED_CHANNELS) || '[]'),
    handlers: JSON.parse(localStorage.getItem(ECHO_CHANNEL_HANDLERS) || '{}'),
    lastActive: localStorage.getItem(ECHO_LAST_ACTIVE)
  })
};

export default createEcho;

Our createEcho.js module provides several key features:

  • Singleton pattern - Only one Echo instance is created
  • Connection state persistence - Saves connection state in localStorage
  • Channel subscription persistence - Tracks subscribed channels
  • Automatic reconnection - Reconnects when the page becomes visible
  • Debugging tools - Global object for connection debugging

Creating a Notification Service

Next, let's create a notification service that uses our Echo instance to subscribe to notification channels and format incoming notifications:

// notificationService.js
import { ref } from "vue";
import createEcho from "./echo";
import { useNotificationStore } from "@/stores/notificationStore";

// Initialization key
const USER_NOTIFICATION_INIT_KEY = 'user_notification_service_initialized';

export const useNotificationService = async (providedUserStore = null) => {
  // Create a fresh Echo instance with the current token
  const echo = createEcho();

  // Get user store (passed or dynamically imported)
  const userStore = providedUserStore || (await getUserStore());

  // Initialize notification store
  const notificationStore = useNotificationStore();

  // Notification type constants
  const notificationTypes = {
    TRIP_CREATED: "trip_created",
    WALLET_FUNDED: "wallet_funded",
    // Add more notification types...
  };

  // Format notification based on type and data
  const formatNotification = (type, data) => {
    const currentTime = new Date();
    const formattedTime = currentTime.toLocaleTimeString([], {
      hour: "2-digit",
      minute: "2-digit",
    });
    const formattedDate = currentTime.toLocaleDateString("en-US", {
      month: "long",
      day: "numeric",
      year: "numeric",
    });

    let message = "";
    let title = "";

    // Format notification based on type
    switch (type) {
      case notificationTypes.TRIP_CREATED:
        title = "Trip Created";
        message = data.message;
        break;
      case notificationTypes.WALLET_FUNDED:
        title = "Wallet Funded";
        message = data.message;
        break;
      // Add more notification types...
      default:
        title = "Notification";
        message = "You have a new notification";
    }

    return {
      id: Date.now(),
      type,
      title,
      message,
      time: formattedTime,
      date: formattedDate,
      fullTime: `${formattedDate} . ${formattedTime}`,
      data,
      status: "unread",
    };
  };

  // Add notification to store and show browser notification
  const addNotification = (notification) => {
    // Add to global notification store
    notificationStore.addNotification(notification);

    // Show browser notification if allowed
    if (Notification.permission === "granted") {
      try {
        new Notification(notification.title, {
          body: notification.message,
          icon: "/favicon.png",
        });
      } catch (error) {
        console.warn("Error displaying browser notification:", error);
      }
    }
  };

  // Helper to get user store dynamically (avoids circular dependency)
  const getUserStore = async () => {
    const { useUserStore } = await import("@/stores/userStore");
    return useUserStore();
  };

  // Subscribe to a private channel with error handling
  const subscribeToPrivateChannel = (channelName, eventName, handler, handlerType) => {
    try {
      // Check if channel already exists
      const existingChannels = echo.connector.pusher.channels.channels;
      if (existingChannels[channelName]) {
        existingChannels[channelName].bind(eventName, handler);
        return existingChannels[channelName];
      }

      // Create new subscription
      const channel = echo.private(channelName);

      // Debug subscription status
      channel.subscribed(() => {
        console.log(`Successfully subscribed to private channel: ${channelName}`);

        // Track this subscription with its handler info
        echo.saveSubscription(channelName, eventName, { type: handlerType });
      });

      // Error handler
      channel.error((err) => {
        console.error(`Channel subscription error for ${channelName}:`, err);
      });

      // Bind the event handler
      channel.listen(eventName, (data) => {
        console.log(`Received event ${eventName} on channel ${channelName}:`, data);
        handler(data);
      });

      return channel;
    } catch (error) {
      console.error(`Exception when subscribing to private channel ${channelName}:`, error);
      return null;
    }
  };

  // Global resubscription handler for saved channels
  window.userResubscribeToChannels = (channels, handlers) => {
    if (!userStore.user?.id) {
      console.error("User ID not available for resubscription");
      return;
    }

    const userId = userStore.user.id;

    channels.forEach(channelName => {
      const channelHandlers = handlers[channelName] || [];

      // Resubscribe based on saved handlers
      channelHandlers.forEach(handlerInfo => {
        // Create appropriate event handlers based on channel and event
        if (channelName === `trip.created.${userId}` && handlerInfo.event === 'TripCreated') {
          subscribeToPrivateChannel(
            channelName,
            handlerInfo.event,
            (data) => {
              const notification = formatNotification(notificationTypes.TRIP_CREATED, data);
              addNotification(notification);
            },
            'TRIP_CREATED'
          );
        } 
        // Add more channel/event combinations...
      });

      // If no handlers found, create based on channel name pattern
      if (!channelHandlers.length) {
        if (channelName === `trip.created.${userId}`) {
          subscribeToPrivateChannel(
            channelName,
            "TripCreated",
            (data) => {
              const notification = formatNotification(notificationTypes.TRIP_CREATED, data);
              addNotification(notification);
            },
            'TRIP_CREATED'
          );
        } 
        // Add more channel patterns...
      }
    });
  };

  // Initialize Echo and subscribe to channels
  const initializeEcho = async () => {
    if (!userStore.user?.id) {
      console.error("User ID not available for notification subscription");
      return;
    }

    const userId = userStore.user.id;
    console.log(`Initializing notifications for user ID: ${userId}`);

    // Mark service as initialized
    localStorage.setItem(USER_NOTIFICATION_INIT_KEY, 'true');

    // Subscribe to user-specific channels
    subscribeToPrivateChannel(
      `trip.created.${userId}`,
      "TripCreated",
      (data) => {
        const notification = formatNotification(notificationTypes.TRIP_CREATED, data);
        addNotification(notification);
      },
      'TRIP_CREATED'
    );

    // Add more channel subscriptions...

    // Subscribe to trip-specific channels if needed
    subscribeToTripChannels();
  };

  // Subscribe to trip-specific channels
  const subscribeToTripChannels = async () => {
    // Get user's active trips
    const userTrips = userStore.activeTrips || [];

    if (userTrips.length === 0) {
      console.log("No active trips found for subscription");
      return;
    }

    // Subscribe to channels for each trip
    userTrips.forEach((trip) => {
      const tripId = trip;

      // Add trip-specific subscriptions...
      subscribeToPrivateChannel(
        `trip.departure.${tripId}`,
        "TripDeparture", 
        (data) => {
          const notification = formatNotification(
            notificationTypes.TRIP_DEPARTURE,
            {
              ...data,
              departure: trip.departure,
              destination: trip.destination,
            }
          );
          addNotification(notification);
        },
        'TRIP_DEPARTURE'
      );

      // Add more trip-specific channels...
    });
  };

  // Request browser notification permission
  const requestNotificationPermission = async () => {
    if (!("Notification" in window)) {
      console.log("This browser does not support desktop notifications");
      return false;
    }

    if (Notification.permission === "granted") {
      return true;
    }

    if (Notification.permission !== "denied") {
      const permission = await Notification.requestPermission();
      return permission === "granted";
    }

    return false;
  };

  // Cleanup function for logout
  const cleanup = () => {
    try {
      console.log("Cleaning up user notification subscriptions...");

      // Use the clearEchoState method to remove localStorage entries
      createEcho.clearEchoState();

      // Remove global resubscribe handler
      delete window.userResubscribeToChannels;

      // Remove the notification initialization flag
      localStorage.removeItem(USER_NOTIFICATION_INIT_KEY);

      // Clear the notification store
      notificationStore.deleteAllNotifications();

      console.log('User notification service cleaned up');
    } catch (error) {
      console.error("Error cleaning up user notifications:", error);
    }
  };

  // Return service methods and state
  return {
    // Get notifications from store
    get notifications() {
      return {
        value: notificationStore.notifications
      };
    },
    get unreadCount() {
      return {
        value: notificationStore.unreadCount
      };
    },
    initializeEcho,
    markAllAsRead: notificationStore.markAllAsRead,
    markAsRead: notificationStore.markAsRead,
    subscribeToTripChannels,
    requestNotificationPermission,
    cleanup,
  };
};

export default useNotificationService;

The notification service:

  • Creates an Echo instance for WebSocket connection
  • Provides formatting for different notification types
  • Manages channel subscriptions and resubscriptions
  • Handles browser notification permissions
  • Offers cleanup functionality for logout

Building the Notification Store

To manage notification state across components, we need a store. In a Vue 3 project, you can use Pinia or a similar state management solution:

// notificationStore.js
import { defineStore } from 'pinia';

export const useNotificationStore = defineStore('notification', {
  state: () => ({
    notifications: [],
  }),

  getters: {
    // Get unread count
    unreadCount: (state) => state.notifications.filter(n => n.status === 'unread').length,

    // Get notifications by status
    getNotificationsByStatus: (state) => (status) => {
      return state.notifications.filter(notification => notification.status === status);
    },
  },

  actions: {
    // Add a new notification
    addNotification(notification) {
      this.notifications.unshift(notification);
    },

    // Mark notification as read
    markAsRead(notificationId) {
      const notification = this.notifications.find(n => n.id === notificationId);
      if (notification) {
        notification.status = 'read';
      }
    },

    // Mark all notifications as read
    markAllAsRead() {
      this.notifications.forEach(notification => {
        notification.status = 'read';
      });
    },

    // Delete a notification
    deleteNotification(notificationId) {
      const index = this.notifications.findIndex(n => n.id === notificationId);
      if (index !== -1) {
        this.notifications.splice(index, 1);
      }
    },

    // Delete all notifications
    deleteAllNotifications() {
      this.notifications = [];
    },
  },
});

UI Components for Notifications

Now let's create the UI components for displaying notifications:

Notification Dropdown Component

<script setup>
import { ref, onMounted, computed, inject } from "vue";
import { useRouter } from "vue-router";
import NotificationModal from "./NotificationModal.vue";
import useNotificationService from "@/services/userNotificationService/notificationService";
import { useUserStore } from "@/stores/userStore"; 
import { useNotificationStore } from "@/stores/notificationStore";

// inject notificationModalRef 
const notificationModalRef = inject('notificationModalRef');

const router = useRouter();
const userStore = useUserStore();
const notificationStore = useNotificationStore();
const notificationService = ref(null);

const emit = defineEmits(['close']);

const tabOptions = ref([
  {
    label: "All",
    value: "all",
  },
  {
    label: "Unread",
    value: "unread",
  },
]);

const currentTab = ref("all");
const selectedNotification = ref(null);
const isModalOpen = ref(false);

onMounted(async () => {
  // Initialize notification service if needed
  if (!notificationService.value) {
    notificationService.value = await useNotificationService(userStore);
  }
});

// Filter notifications based on selected tab
const filteredNotifications = computed(() => {
  if (currentTab.value === "all") {
    return notificationStore.notifications;
  } else {
    return notificationStore.getNotificationsByStatus('unread');
  }
});

// Get counts from store
const unreadCount = computed(() => notificationStore.unreadCount);
const notificationCount = computed(() => notificationStore.notifications.length);

const handleTabChange = (tab) => {
  currentTab.value = tab;
};

const openNotificationDetails = (notification) => {
  // Mark as read
  notificationStore.markAsRead(notification.id);

  selectedNotification.value = notification;
  isModalOpen.value = true;
};

const closeModal = () => {
  isModalOpen.value = false;
  selectedNotification.value = null;
};

const markAllAsRead = () => {
  if (unreadCount.value > 0) {
    notificationStore.markAllAsRead();
  }
};

// Format notification time
const formatNotificationTime = (notification) => {
  if (!notification) return '';
  return notification.time;
};

// Redirect to settings
const redirectToSettings = () => {
  router.push({ name: "Settings", query: { tab: "notifications" } });
  emit('close');
};
script>

In the header component, we'll add a notification icon to show unread count and toggle the notification dropdown:

<script setup>
import { ref, onMounted, onBeforeUnmount, computed, provide } from "vue";
import NotificationDropDown from "./NotificationComponents/NotificationDropDown.vue";
import { useUserStore } from "@/stores/userStore";
import { useNotificationStore } from "@/stores/notificationStore";
import useNotificationService from "@/services/userNotificationService/notificationService";

// Notification modal reference
const notificationModalRef = ref(null);

// Provide modal reference to child components
provide("notificationModalRef", notificationModalRef);

const userStore = useUserStore();
const notificationStore = useNotificationStore();

// Notification service and state
const USER_NOTIFICATION_INIT_KEY = 'user_notification_service_initialized';
const notificationService = ref(null);
const openNotificationDropdown = ref(false);
const notificationDropdownRef = ref(null);
const notificationIconRef = ref(null);

// Get unread count from the store
const unreadCount = computed(() => notificationStore.unreadCount);

// Toggle notification dropdown
const toggleNotificationDropdown = () => {
  openNotificationDropdown.value = !openNotificationDropdown.value;
  // Close other dropdowns
  openProfileDropdown.value = false;
};

// Function to close dropdowns when clicking outside
const handleClickOutside = (event) => {
  // Ignore modal close actions
  if (event.target.closest('[data-modal-action]')) {
    return;
  }

  // Check for notification dropdown
  if (
    openNotificationDropdown.value && 
    !event.target.closest('[data-notification-trigger]') && 
    notificationDropdownRef.value && 
    !notificationDropdownRef.value.contains(event.target) &&
    (!notificationModalRef.value || !notificationModalRef.value.contains(event.target))
  ) {
    openNotificationDropdown.value = false;
  }
};

// Clean up notification service before logout
const cleanupNotifications = async () => {
  try {
    console.log("Cleaning up notification subscriptions...");

    // If we have a notification service instance, use it
    if (notificationService.value) {
      await notificationService.value.cleanup();
    } 
    // Otherwise, get a new instance to clean up
    else if (localStorage.getItem(USER_NOTIFICATION_INIT_KEY) === 'true') {
      const service = await useNotificationService(userStore);
      await service.cleanup();
    }

    // Remove initialization flag and clear store
    localStorage.removeItem(USER_NOTIFICATION_INIT_KEY);
    notificationStore.deleteAllNotifications();

    console.log("Notification service cleaned up");
  } catch (error) {
    console.error("Error cleaning up notifications:", error);
  }
};

// Provide cleanup function to child components
provide("cleanupNotifications", cleanupNotifications);

// Logout handler
const logout = async () => {
  // Clean up notification service before logout
  await cleanupNotifications();

  // Perform logout API call
  http()
    .get("auth/logout")
    .then((response) => {
      if (response.status === 200 || response.status === 201) {
        authStore.deleteToken();
        userStore.deleteUser();
        router.replace({ name: "signin" });
      }
    });
};

// Set up event listeners
onMounted(async () => {
  document.addEventListener("click", handleClickOutside);

  // Initialize notification service if needed
  if (!notificationService.value) {
    notificationService.value = await useNotificationService(userStore);

    if (localStorage.getItem(USER_NOTIFICATION_INIT_KEY) !== 'true') {
      await notificationService.value.initializeEcho();
      await notificationService.value.requestNotificationPermission();
    }
  }
});

// Clean up event listeners
onBeforeUnmount(() => {
  document.removeEventListener("click", handleClickOutside);
});
script>

<template>
  
    

     class="header-controls">
      
      
        ref="notificationIconRef"
        class="notification-icon"
        @click="toggleNotificationDropdown"
        data-notification-trigger
      >
        
         v-if="unreadCount > 0" class="unread-badge">
          {{ unreadCount }}
        
         src="@/assets/Icons/notifications.svg" alt="notifications" />
      

      
    

    
     ref="notificationDropdownRef">
      
        v-if="openNotificationDropdown"
        @close="toggleNotificationDropdown"
      />
    
  
template>

Testing and Debugging

For effective debugging of real-time WebSocket connections, our Echo service includes a global debug object that can be accessed from the browser console:

// Access the debug object in your browser console
window.userEchoDebug.getConnectionState() // Check connection state
window.userEchoDebug.getStoredState() // View stored channel subscriptions
window.userEchoDebug.getInstance() // Access the Echo instance

When testing your notification system, check these common issues:

  • Authentication Errors: Ensure that your Echo instance has the correct authentication token for private channels
  • Channel Naming: Verify the channel names match between backend and frontend
  • Event Names: Make sure event names are consistent in Laravel and Vue
  • Connection Status: Check connection state if notifications aren't being received
  • Browser Compatibility: Test browser notifications on different platforms

Best Practices

Based on our implementation, here are some best practices for real-time notification systems:

  • Singleton Pattern: Use a singleton pattern for your Echo instance to avoid multiple connections
  • Connection Persistence: Store connection state in localStorage to improve reconnection
  • Channel Subscription Persistence: Save subscribed channels to restore them after page refresh
  • Visibility Change Handling: Reconnect when the page becomes visible
  • Clean Separation of Concerns:
    • Echo client handles connection
    • Notification service manages business logic
    • Store manages state
    • UI components handle presentation
  • Proper Cleanup: Clean up resources and disconnect when users log out
  • Request Notification Permissions Early: Ask for browser notification permission during initialization
  • Error Handling: Add robust error handling for all WebSocket operations
  • Format Notifications Consistently: Use a consistent structure for all notification types
  • Provide Fallbacks: Use in-app notifications as a fallback when browser notifications are denied

Conclusion

Building a real-time notification system with Vue 3, Laravel Echo, and Pusher provides a seamless user experience for your web application. The architecture presented in this article offers a robust solution that:

  • Maintains connection state across page refreshes
  • Automatically reconnects when necessary
  • Provides persistent channel subscriptions
  • Offers both in-app and browser notifications
  • Includes proper cleanup on logout

By following this implementation, you've created a complete notification system that's scalable and maintainable. The modular approach makes it easy to add new notification types or modify existing ones without affecting the core functionality.

Remember to secure your WebSocket connections properly and handle authentication errors gracefully. With these considerations in mind, your notification system will provide a reliable and engaging experience for your users.

What improvements or additional features would you add to this notification system? Let me know in the comments!