All articles

Real-time notifications in Go: WebSocket bell, VAPID push, and multi-source event routing

Suman Akkisetty
Founder, KaryoSpace
April 2026
9 min read

KaryoSpace has a real-time notification bell in the top bar that updates without polling. Every important event across the platform — new email, DM, assigned task, incident created, calendar invite, @mention — fires a notification that appears in the bell within milliseconds of the triggering action. Here is the architecture.

The notification document

Every notification is a MongoDB document with a common schema regardless of which module created it. The type field gates filtering; the link field tells the UI where to navigate on click.

type Notification struct {
    ID         primitive.ObjectID `bson:"_id"`
    UserID     string             `bson:"user_id"`
    OrgID      string             `bson:"org_id"`
    Type       string             `bson:"type"` // email|incident|task|mention|calendar
    Title      string             `bson:"title"`
    Body       string             `bson:"body"`
    Link       string             `bson:"link"`
    Read       bool               `bson:"read"`
    CreatedAt  time.Time          `bson:"created_at"`
    SourceID   string             `bson:"source_id"` // email ID, incident ID, etc.
    SourceMod  string             `bson:"source_mod"`
}

Creating notifications from modules

Each module calls createNotification() at the moment the event occurs. No event bus, no message queue. Direct function call in the same goroutine, with the MongoDB write happening synchronously so the notification exists before the HTTP response returns to the caller.

// Called from incident/handlers.go when an incident is assigned
func notifyIncidentAssigned(inc *Incident, assigneeID string) {
    createNotification(Notification{
        UserID:    assigneeID,
        OrgID:     inc.OrgID,
        Type:      "incident",
        Title:     "Incident assigned to you",
        Body:      inc.Title,
        Link:      fmt.Sprintf("/incidents/%s", inc.ID.Hex()),
        SourceID:  inc.ID.Hex(),
        SourceMod: "incident",
    })
    // Push to WebSocket immediately after storing
    pushNotificationWS(assigneeID, notif)
}

WebSocket push

After storing the notification in MongoDB, the same function pushes it to the user's active WebSocket connection via the messaging hub. This is the same WebSocket connection used for chat messages. The hub routes by user ID, so the push reaches all browser tabs the user has open simultaneously.

func pushNotificationWS(userID string, notif Notification) {
    payload, _ := json.Marshal(WSMessage{
        Type:    "notification.new",
        Payload: notif,
    })
    hub.sendToUser(userID, payload)
}

// In the browser (topbar.html)
ws.addEventListener('message', e => {
    const msg = JSON.parse(e.data);
    if (msg.type === 'notification.new') {
        incrementBell();
        showToast(msg.payload.title, msg.payload.body);
    }
});

Polling fallback

WebSocket connections drop for many reasons: idle timeouts, mobile network switches, browser tab backgrounding. When the WebSocket is not available, the bell falls back to polling GET /api/notifications/unread-count every 60 seconds. The count endpoint is a single MongoDB CountDocuments call with an index on user_id + read + created_at. It runs in under 2ms.

Type filter pills

The notification dropdown shows filter pills: All, Email, Incident, Task, Mention, Calendar. Selecting a pill filters the visible notifications in-browser from the cached set. The full list is fetched once when the bell is opened; subsequent filter changes are instant client-side operations.

// GET /api/notifications?limit=50
// Returns last 50 notifications for current user, sorted newest first
func listNotificationsHandler(w http.ResponseWriter, r *http.Request) {
    userID := getUserID(r)
    opts := options.Find().
        SetSort(bson.D{{Key: "created_at", Value: -1}}).
        SetLimit(50)
    cursor, _ := db.Collection("notifications").Find(ctx,
        bson.M{"user_id": userID, "org_id": getOrgID(r)}, opts)
    // ...
}

VAPID web push

When a user installs KaryoSpace as a PWA and grants notification permission, the browser registers a push subscription. The subscription endpoint and auth keys are stored in MongoDB. When a notification is created for an offline user (no active WebSocket), the server sends a Web Push via the VAPID protocol.

func sendWebPush(userID string, notif Notification) error {
    subs, _ := getPushSubscriptions(ctx, userID)
    for _, sub := range subs {
        payload, _ := json.Marshal(map[string]string{
            "title": notif.Title,
            "body":  notif.Body,
            "url":   notif.Link,
        })
        // webpush.SendNotification handles VAPID header signing
        resp, err := webpush.SendNotification(payload, &sub, &webpush.Options{
            VAPIDPublicKey:  os.Getenv("VAPID_PUBLIC_KEY"),
            VAPIDPrivateKey: os.Getenv("VAPID_PRIVATE_KEY"),
            TTL:             300, // 5 minutes
        })
        if resp != nil && resp.StatusCode == 410 {
            // Subscription expired — remove it
            removePushSubscription(ctx, sub.Endpoint)
        }
    }
    return nil
}

The 410 status check is critical. When a user uninstalls the PWA or revokes push permission, the push service returns 410 Gone. If you do not remove the subscription on 410, you accumulate dead subscriptions and start hitting rate limits.

Mark read

Clicking a notification marks it read. The mark-read API uses a MongoDB UpdateOne with the notification ID and a user_id filter. The user ID filter is not redundant — it prevents a user from marking another user's notification as read by guessing an ID. Every mutation in the notification API includes both notification_id and user_id in the filter.

Get in touch

Questions about KaryoSpace or this article?

Contact Us
We read every submission

Message Sent!

Thanks for reaching out. We'll get back to you shortly.

Send Feedback
We read every submission
Build Stats
Loading stats…