article · Software Engineering
Notifications using Server-Sent Events
Lessons I learnt from implementing a notification feature
August 31, 2026 · 7 min read
- #notifications
- #server-sent events
- #architecture
- #typescript
Preliminaries
For this piece, I mostly focus on the steps, decisions, and core concepts involved in building a reliable notification system with Server-Sent Events (SSE). My examples use AdonisJS for the API and SvelteKit for the web application because those are the tools I work with. You should check them out, by the way ;-)
Feature Needs
- A live notification counter
- A notification centre accessible from any page, showing the latest notifications (paginated) with actions such as marking them as read and opening them
- A notifications page
A notification centre, usually a sidebar that opens from the side, top, or bottom, is a key UI feature that improves the user experience by providing quick access to notifications from any page. It streamlines the user experience by eliminating the extra click required to visit a dedicated notifications page to review and act on a notification.
Additionally, redirecting users to a dedicated notifications page just to act on a notification meant that they first had to filter for unread notifications or go through the list manually. I therefore opted for a notification centre as the primary feature. It shows unread messages in descending order, with action buttons for opening a notification, marking one or all notifications as read, and navigating to the notifications page. The notifications page serves as a supporting page for more features, such as filtering, searching, viewing all notifications, and marking one or all as read.
The Journey
AdonisJS offers the Transmit package, which simplifies SSE implementation on the server and client. Transmit handles the SSE routes, channels, and broadcasting and provides ways to authenticate and authorise users for private subscriptions. In my case, I use the application’s usual authentication middleware at the Transmit route level and Transmit channel authorisation to ensure users only subscribe to their channels as shown below:
// Authentication in start/routes.ts
transmit.registerRoutes((route) => {
route.middleware(middleware.auth({ guards: ['web'] }));
}); // Authorisation in start/transmit.ts
import transmit from '@adonisjs/transmit/services/main';
transmit.authorize<{ accountId: string }>(
'accounts/:accountId/notifications',
(ctx, { accountId }) => ctx.auth.user?.id === accountId
); In my application, I created notifications as part of the same database transaction as the flows that produced them. For example, I sometimes needed to record a change that other parts of the application depended on and inform the users affected by that change. To achieve this, I wrapped all the processes that should succeed or fail together in a database transaction, including saving the notification. I then registered the Transmit signal to run once the transaction committed. This sequence accounts for the following failure scenarios:
- Rolled-back transactions cannot transmit a live notification to the client.
- A failed SSE transmission does not prevent notification persistence, as it could if the transmission occurred within the transaction.
Here is a code snippet showing how I did it:
import { DateTime } from 'luxon';
import transmit from '@adonisjs/transmit/services/main';
import db from '@adonisjs/lucid/services/db';
import Notification from '#models/notification/message';
import type { NotificationInput } from '#types/notification';
import type { TransactionClientContract } from '@adonisjs/lucid/types/database';
export default class NotificationService {
async create(input: NotificationInput, trx: TransactionClientContract) {
const notification = await Notification.create(
{
...input,
createdAt: DateTime.now()
},
{ client: trx }
);
trx.on('commit', () =>
transmit.broadcast(`accounts/${notification.recipientAccountId}/notifications`, notification)
);
return notification;
}
} On the client, Transmit provides a browser library for connecting to the API’s SSE routes and subscribing to notification channels.
Initial Solution
My initial mental model was to have a notifications route that loaded data through SvelteKit’s load functions and updated it through form actions. The API sent the complete notification through SSE, that is, the message and metadata. The client then appended it to a notification list, displayed the unread messages, and updated the counter. This approach also allowed the notifications page to meet my feature requirements.
Problems
I encountered the following problems during implementation:
1. The live connection belongs in the browser
SvelteKit provides server-side route files such as +server.ts, +page.server.ts, and +layout.server.ts. In my setup, these files are useful for enforcing authentication and authorisation before making ordinary API read and write requests.
SSE is a server-to-client connection, so a direct server-to-client setup is ideal. With SvelteKit, you could proxy the SSE via a +server.ts API endpoint and then have the page call the proxy endpoint from the component. I initially attempted this approach to maintain my server-to-server HTTP(S) pattern but ended up fighting native browser behaviour with little benefit. Besides, the three registered Transmit routes are the only ones exposed in the browser’s DevTools, while the channel remains private.
I therefore created the Transmit client inside a Svelte component. The component subscribes to the user’s notification channel, listens for signals, and closes the subscription when it is destroyed.
This creates two authentication points:
- SvelteKit protects normal page loads, form actions, and its server routes.
- AdonisJS protects the Transmit routes and authorises the user’s private notification channel.
The second point matters because SvelteKit’s server-side checks do not protect a connection made from the browser directly to AdonisJS.
The Transmit client includes the session cookie, if available, in the subscription request. AdonisJS uses this cookie to authenticate the request.
2. API and client state synchronisation
I realised that my initial implementation naively assumed that receiving a new live event was the only cause of change in the notification list.
Other events can also make that list stale:
- A notification is marked as read.
- A new notification is created while another refresh is running.
- The live connection disconnects and reconnects.
- The browser goes offline and later comes back online.
- The user leaves the tab and returns later.
- Several refresh requests happen close together.
These events quickly make a client-managed notification list complicated. The client would need to append new notifications, remove read ones, prevent duplicates, maintain the unread count, resolve event ordering, and recover anything missed while disconnected.
I needed a way to reduce that client complexity without introducing another synchronisation system.
Solution: SSE as an invalidation signal/event
The examples in the AdonisJS documentation on SSE channels gave me an idea: I could use Transmit as a signal instead of a carrier for the notification data, thereby simplifying the frontend design. The API does the following once the transaction commits:
trx.on('commit', () =>
transmit.broadcast(`accounts/${notification.recipientAccountId}/notifications`, {
type: 'notifications.changed'
})
); When the signal arrives, the client requests the latest unread notifications from the API and replaces its displayed data with the response.
This moved notification-list management back to the API. The client no longer needs to merge messages, prevent duplicates, resolve event ordering, or repair missed events.
The trade-off is one additional HTTP request after each live signal. For my notification volume, that was a much better trade-off than maintaining two sources of client state. This improves reliability in two ways: if the database transaction preceding the live notification rolls back, the user receives no notification; if the transaction commits but the transmission fails, the database record still exists.
The result still feels live to the user, but the notification data is reconciled with the database record.
Manageable client-side complexity
Eliminating client state coordination based on SSE messages made it easier to handle the other notification state-invalidating events I pointed out earlier.
When the client receives that signal, it requests the latest unread notifications from the API. The same refresh can happen when:
- The browser window regains focus.
- The user returns to the tab.
- The browser comes back online.
- Transmit reconnects.
- A notification is marked as read.
- The user manually retries a failed request.
However, another problem appears when several events happen close together. For example, an SSE signal may arrive while a refresh request is already running. Starting another request immediately would create racing requests, while ignoring the signal could leave the client with stale data.
I handled this by allowing only one notification refresh to run at a time.
When another refresh is requested during the current one, the client records that request and returns the promise for the running task. Once the current request finishes, the refresh loop checks whether another request arrived. If it did, the loop fetches the notifications one more time.
The flow looks like this:
- An event requests a refresh.
- If no refresh is running, the client starts one.
- If one is already running, the client records that another refresh is needed.
- The current request finishes.
- If another refresh was requested, the client fetches once more.
- The refresh task and loading state are cleared.
This prevents competing requests without losing changes that happen during an active request.
Marking a notification as read uses the same flow. After the API records the read state, the client refreshes the unread list. This removes the notification that was marked as read while also retrieving any new notifications created during that time.
Failure handling and cleanup
Treating SSE as a signal also made failure handling clearer because notification-loading failures and live-connection failures can be debugged separately without worrying whether one fix will break another.
If loading notifications fails, the client cannot confirm the latest state. In this case, it displays the loading error and provides a Retry button.
If the live connection fails, previously loaded notifications are still valid. The user has only lost immediate updates. The client can recover by refreshing when the browser reconnects, the window regains focus, the tab becomes visible, or the page is reloaded.
If a mark-as-read request fails, the notification remains in the unread list, and the user receives an error message.
The component must also clean up after itself. When it is destroyed, it:
- Stops listening for Transmit messages.
- Deletes the notification subscription.
- Closes the Transmit connection.
- Cancels unfinished notification requests.
- Prevents completed requests from updating a component that no longer exists.
This separation also helps when debugging. If live delivery fails but a page refresh retrieves the notification, the problem is in the live connection. If a successful API refresh does not contain the expected notification, the investigation can move to notification creation, recipient selection, or API query handling.
The user may temporarily miss a live update, but they do not lose the notification itself because the saved API state will be displayed eventually.