Realtime notifications come down to how quickly a connected client finds out something new happened. A handful of services can provide this, including Ably, Pusher, and PartyKit, or a raw WebSocket server of your own. Oolvay ships with Ably pre-integrated as the choice already made for you, so you are not left wiring up a realtime provider from scratch.
This page assumes the notification model itself, types, channels, and preferences, covered in Notifications, and focuses only on delivery speed.
Notifications themselves are created, stored, and queried entirely through the database, independent of whatever realtime provider sits on top. What Ably adds is speed.
This makes Ably entirely optional and off by default. If you choose not to use it, the application does not lose any functionality. It simply falls back to polling the database on a fixed interval, pollingIntervalMs. The only difference between the two paths is speed, instant through a push, or delayed by up to pollingIntervalMs through a poll, at the cost of every connected client querying your database that often instead of not at all.
Ably is not where notification data lives. It carries no state, no history, and no per-user targeting. Its entire job in this system is to publish a single, content-free signal, “a new notification exists,” to every client subscribed to a shared notifications channel.
Everything else, who a notification belongs to, whether it has been read, what it says, lives in the database, in the notificationEvent and userNotification tables. The signal Ably delivers does not say what changed or for whom. It is a prompt for connected clients to go ask the database themselves.
0 / 2,000 characters
const ablyServer = createAblyServer()
if (ablyServer) {
try {
await ablyServer.channels.get("notifications").publish("new-notification", {
postId: publishedPost.id,
notificationType: publishedPost.notificationType,
})
} catch (error) {
logger.error("Failed to publish Ably notification", {
error,
postId: publishedPost.id,
})
}
}Notice that the database insert happens first, and the Ably publish happens after, wrapped in its own try/catch. If the publish fails, the function logs the failure and moves on. The notification itself was already written to the database before Ably was ever involved, so a failed publish means a delayed notification, not a lost one. The next poll, or the next time a client reconnects, picks it up regardless.
Every connected client subscribes to the same channel, notifications, and the client SDK connects with a hardcoded identity:
ablyClientSingleton = new Ably.Realtime({
key: process.env.NEXT_PUBLIC_ABLY_CLIENT_KEY,
clientId: "anonymous",
})This is intentional, not an oversight. Ably is not being used to target specific users or to carry per-user payloads. Every connected client, regardless of who they are, receives the exact same new-notification event at the exact same time. What happens next, refetching that specific user’s notifications from the database, is what actually determines what they see. The targeting and the unread state are entirely server-side concerns, resolved through getEligibleUsers() and the userNotification table, not through Ably.
If you need genuinely per-user realtime channels, for example to push a
payload only a specific user should receive, you would need to move beyond
this shared channel and clientId: "anonymous" pattern, into Ably’s
per-client authentication and channel scoping. That is a deliberate extension
point, not something Oolvay sets up for you out of the box.
Ably is consumed in two separate places, and they do not behave identically when something goes wrong. This is worth knowing before you go looking for a bug that is actually just this asymmetry.
lib/notifications/use-notifications.ts, used for the notification bell, retries before giving up:
const maxRetries = siteConfig.notifications.ably.maxRetries
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await channel.subscribe("new-notification", () => void refresh())
return
} catch (error) {
const isLastAttempt = attempt === maxRetries
if (!isLastAttempt) {
await new Promise<void>((resolve) =>
setTimeout(resolve, siteConfig.notifications.ably.retryDelayMs)
)
if (!mounted) return
continue
}
// After every attempt has failed, fall back to polling and report once
interval = setInterval(
() => void refresh(),
siteConfig.notifications.pollingIntervalMs
)
}
}If the subscribe call fails, it retries up to ably.maxRetries times, waiting ably.retryDelayMs between attempts. If every attempt fails, it falls back to polling at pollingIntervalMs and reports the failure once, through both logger.warn() and Sentry.captureException(), guarded by a module level flag so a flaky connection does not spam your error tracker with repeated reports.
notifications-inbox-client.tsx, used for the full notifications inbox page, logs a single failed subscribe attempt but does not retry or fall back to polling:
if (siteConfig.notifications.ably.enabled) {
const ablyClient = getAblyClient()
const channel = ablyClient?.channels.get("notifications")
channel
?.subscribe("new-notification", () => void refresh())
.catch((error) =>
logger.warn("Ably subscribe failed on inbox page", { error })
)
return () => {
mounted = false
channel?.unsubscribe()
}
}If the subscribe call fails, this is logged through logger.warn(), but unlike the bell, there is no retry and no fallback to polling. The inbox page simply stops receiving realtime updates until the next full page load. This is a smaller gap than before, the failure is now at least visible in your logs, but the inbox page and the bell are still not the same code path, and only the bell recovers on its own.
If you are debugging a report that says “the notification bell updates instantly but the full inbox page does not,” this is almost certainly why. It is a real difference in the current implementation, not a sign that something is broken.
Whenever Ably is disabled, unconfigured, or, in the bell’s case, has exhausted its retries, both consumers fall back to the same mechanism: setInterval, refreshing on a fixed schedule defined by pollingIntervalMs.
notifications: {
pollingIntervalMs: 60000, // 60 seconds
},Any config variable ending in Ms is measured in milliseconds. 1,000 ms
equals 1 second, so 60000 above means 60 seconds. You will see this same
suffix again shortly with retryDelayMs.
This is the same fallback path whether Ably was never configured at all, or was configured and is currently unreachable. From a connected client’s perspective, both situations look identical: notifications still arrive, just on a fixed interval instead of instantly.
notifications: {
retentionDays: 90,
pollingIntervalMs: 60000, // 60 seconds
ably: {
enabled: false,
maxRetries: 3,
retryDelayMs: 2000,
},
},Defaults to false. While false, neither createAblyServer() nor getAblyClient() ever construct a real Ably connection, regardless of whether ABLY_API_KEY or NEXT_PUBLIC_ABLY_CLIENT_KEY are present in .env.local. Both functions return null immediately, and every consumer of them is written to treat a null client as “use polling,” not as an error.
Used only by the bell’s notification hook. maxRetries controls how many times it attempts to subscribe before giving up and falling back to polling. retryDelayMs controls how long it waits between attempts. The inbox page does not currently use either of these, since it has no retry loop of its own.
Controls how often clients refetch notifications when not using Ably, and also how often the bell’s notification hook polls after it has exhausted its Ably retries. The same value governs both.
Lowering this value makes notifications feel fresher, but every connected client queries your database that much more often. A drop from 60 seconds to, for example, 5 seconds means roughly 12 times the query volume per connected client. Raise it if database load matters more to you than freshness, or use Ably instead, since a push does not carry this same per-client polling cost.
Covered on the Notifications page, since it applies regardless of delivery mechanism.
Two separate keys are required, one for the server, one for the browser. They are not interchangeable, and using the wrong one in the wrong place will not work correctly even though both are valid Ably API keys.
ABLY_API_KEY=
NEXT_PUBLIC_ABLY_CLIENT_KEY=ABLY_API_KEY is used only in lib/ably/server.ts, to publish notification events from server-side code such as sendPostNotifications(). It is never exposed to the browser.
NEXT_PUBLIC_ABLY_CLIENT_KEY is used only in lib/ably/client.ts, to subscribe from the browser. It is intentionally a separate, public-safe key from ABLY_API_KEY.
Do not reuse your full Ably API key as NEXT_PUBLIC_ABLY_CLIENT_KEY. Create a
dedicated key scoped to subscribe-only access on the channels your client
actually needs, the same way you would scope down any credential before
exposing it to the browser. See the setup steps below.
Create an account at ably.com if you do not already have one, and create an app for this project.
Create a server-side API key.
In the Ably dashboard, go to your app’s API Keys tab. Use the default root key, or create a new key with publish access, for server-side use. Copy it into your environment variables:
ABLY_API_KEY=Create a separate, browser-safe API key.
Create a second key, scoped to subscribe-only capability, since this key will be visible in client-side code. Copy it into your environment variables:
NEXT_PUBLIC_ABLY_CLIENT_KEY=Enable Ably in config/site.ts.
notifications: {
ably: {
enabled: true,
},
},Restart your development server.
bun devTrigger a notification by publishing a blog post that calls , and confirm the notification bell updates without waiting for the next polling interval.
This means Ably is disabled, unconfigured, or unreachable, and the app has fallen back to polling. Check that ably.enabled is true, that both ABLY_API_KEY and NEXT_PUBLIC_ABLY_CLIENT_KEY are set, and that the keys are valid for the Ably app you created.
This is the asymmetry described above under Two consumers, two different fallback behaviors. The inbox page logs a failed subscribe attempt, but still has no retry or fallback logic of its own. Reloading the page is currently the only way to recover its realtime connection if it has failed.
Ably is not responsible for who sees what. Every connected client receives the same untargeted signal on the same shared channel. If notifications are reaching the wrong people, the issue is in getEligibleUsers() or the userNotification rows it produces, not in the Ably configuration.
Only the bell’s hook, use-notifications.ts, reports fallback failures to Sentry, and only once per session, guarded by a module level flag. The inbox page logs a warning through logger.warn() on a failed subscribe, but does not report to Sentry. If you need Sentry visibility into inbox page failures too, that reporting would need to be added there separately.
Realtime delivery, like analytics and logging, is additive. Disabling it does not remove any notification functionality, it only changes how quickly connected clients find out about something that already happened.
sendPostNotifications()