Mercure notifications
Use Mercure for in-app notifications such as mentions, unread counts, and account activity. Publish private updates on a topic scoped to each user.
#What "notification" means here
Use a user topic or a shared announcement topic:
| Audience | Example | Best fit |
|---|---|---|
| Per-user | "You have 3 new messages" | One topic per user, JWT-authorized. |
| Broadcast | "System maintenance at 8pm" | A shared topic, no auth needed if public. |
You can ship both over the same connection.
#Per-user Mercure notifications
Each user subscribes to a topic that's theirs:
const url = new URL("https://hub.example.com/.well-known/mercure");
url.searchParams.append(
"match",
`https://example.com/users/${userId}/notifications`,
);
const es = new EventSource(url, { withCredentials: true });
es.onmessage = (e) => {
const notif = JSON.parse(e.data);
showToast(notif);
setBadge(notif.unread);
};
The cookie carries an access token scoped to that user only:
{
"iss": "https://example.com",
"aud": "https://hub.example.com/.well-known/mercure",
"exp": 4102444800,
"authorization_details": [
{
"type": "https://mercure.rocks/authorization-detail",
"actions": ["subscribe"],
"topics": [
{ "match": "https://example.com/users/42/notifications" },
{ "match": "https://example.com/site/announcements" }
]
}
]
}
The following pseudocode assumes an application publish helper that sends authenticated form-encoded requests. Include the current unread count in the payload and mark the update private:
def notify_user(user_id: str, payload: dict) -> None:
publish(
topic=f"https://example.com/users/{user_id}/notifications",
data=json.dumps(payload),
private=True,
)
The hub delivers the private update only to matching subscribers whose tokens grant access to that user's topic.
#Broadcast announcements over Mercure
Add this matcher before creating the EventSource; changing the URL object does not update an existing connection:
url.searchParams.append("match", "https://example.com/site/announcements");
Publish without private=on. Every matching subscriber gets it. Tokenless connections require anonymous on the hub.
#Notification badge counters with Mercure
Two patterns, depending on how authoritative you need the count:
1. Server tells you the count. The notification payload includes the new total:
{ "type": "mention", "from": "alice", "unread": 7 }
Render notif.unread as the authoritative count. Include a version or timestamp if concurrent publishers can produce counts out of order.
2. Client increments locally. The payload is just the notification; the client adds 1 to its local count. The page resets the count on a separate event when the user reads it:
es.addEventListener("read", (e) => {
const { count } = JSON.parse(e.data);
setBadge(count);
});
Local increments need deduplication and periodic reconciliation with the server. Replayed notifications and concurrent tabs can otherwise inflate the count.
#Multi-tab notification consistency
A user with three tabs open shouldn't get the same toast three times, but they should all see the badge update when one tab reads a message.
Coordinate tab behavior:
- Show toasts in the most-recently-active tab only. Track activity via the
Page Visibility APIand theBroadcastChannelAPI; the active tab handles toasts, others suppress them. - Update the badge in every tab. They all subscribe to the same topic and receive the same events.
This is a UI concern, not a Mercure concern. The hub delivers the same event to every connection; you decide what the UI does with it.
#Combining Mercure with Web Push for offline users
Mercure delivers to connected clients. For a user with the app closed, you need Web Push (or APNs / FCM on mobile). The two complement each other:
- User online -> Mercure pushes the in-app notification.
- User offline -> Web Push pings the OS notification center.
Presence can help choose a delivery channel, but a connected client is not proof that the user saw a notification. Persist unread state and use application acknowledgments when delivery matters.
#Notification read receipts over Mercure
When the user opens a notification, post a read event to your origin, which publishes back over Mercure to update all of the user's tabs:
def mark_read(user_id: str, notif_id: str) -> None:
db.mark_read(user_id, notif_id)
publish(
topic=f"https://example.com/users/{user_id}/notifications",
data=json.dumps({"notif_id": notif_id, "count": db.unread_count(user_id)}),
type="read",
private=True,
)
Each tab uses addEventListener("read", ...) on its notifications stream, as shown above. The application's publish helper must pass type="read" as the Mercure form field.
#Rate limiting publishers
A bug or a runaway loop that publishes a notification per millisecond is a real risk. Mitigations:
- Coalesce on the publisher side: debounce per user before emitting.
- Hub-level rate limits. Mercure Enterprise includes
caddy-ratelimit, so you can configure publisher rate limits without building a custom binary. Mercure Cloud applies the limits of your plan. Adding this module to the open-source hub requires a custom build.
#Privacy and authorization
Notifications often carry personal data. A few rules:
- Always mark notification updates
private=on. - Authorize per-user: scope grants to that user's notification topics.
- Use opaque user identifiers in topics if logs must not contain personal information. Subscription topic names appear in request URLs; access tokens belong in cookies or headers.
- Consider end-to-end encryption if the hub operator should not see the content.
#Next steps
- Authorization: minting per-user tokens.
- Active subscriptions: knowing whether the user is online.
- Live data: for system-wide signals that aren't user-scoped.