Telling one person vs. telling everyone
The problem: something happened in your app and somebody should know. It's your turn in the game. The schedule moved. A round started. The instinct from every other platform is to reach for a notification API and then argue with it about audiences, topics, and subscriptions.
The pattern: two completely different mechanisms, chosen by how many people you're talking to.
- One person →
ctx.notifyfrombackend.js. A real notification: bell, inbox, push. - Everyone → no notification primitive at all. Write the change into a
database the audience already reads. Their
useLiveQueryputs it on screen the moment it commits, for exactly the people entitled to see it, because the platform resolves that audience per reader at read time.
The second half is the one people don't expect. There is no broadcast call to find, because the database already is the broadcast — and unlike a topic subscription, it can never fan out to somebody who shouldn't have seen the document.
Telling one person: ctx.notify
A turn-based game. Somebody moves; the opponent should hear about it.
js// backend.js
export async function onChange(event, ctx) {
if (event.dbName !== "games" || event.deleted) return;
const game = event.doc;
if (game.type !== "game" || !game.toMove) return;
try {
await ctx.notify({
toHandle: game.toMove,
body: `${game.lastMoveBy} played ${game.lastMove} — your turn.`,
dedupeKey: `game-${game._id}-turn-${game.turn}`,
});
} catch (err) {
ctx.log("warn", "notify failed", { game: game._id, reason: err.message });
}
}
The whole contract:
- One object, three required fields.
await ctx.notify({ toHandle, body, dedupeKey }). All three must be present and non-empty. - It throws on failure and resolves with nothing on success. There is no
result to inspect — a missing field, an over-long body, or a handle outside
the app rejects the promise. Usually the surrounding work should survive an
undeliverable message, so wrap it in
try/catchand carry on, as above. toHandlemust already be a participant of this app: the owner, who always qualifies, an accepted invite, an approved request, a handle named in the access function's persisted grant state, or somebody whose authenticated write reached the app (anonymous writes don't count). Invite, request, and writer membership resolve the person through their bound handle; the grant-state clause matches the named handle itself. The check runs on the platform, not on your handler's say-so, and explicit revocations deny anyone but the owner. Passing the check plus valid fields writes the notification into the inbox history of a recipient the platform can resolve — an unresolvable handle fails the call right after the participation check. Their "Messages from a vibe" switch silences the live bell, the push, and the email — not the history.- Your access function is part of that gate. The grant-state clause above
is the one your own code controls: putting a handle in a grant makes that
person notifiable, even if they never acted — the check reads what your
access function persisted, not proof the recipient agreed. For the opt-in
kind of message — your turn, your order shipped — write grants the way you'd
seat guests who take their own chairs: a turn notification goes to a seat
its owner took themselves. Let each player take their own seat (a join
write where the new handle must equal
user.userHandle) rather than letting one user name another. That convention is for notifiable seats, not for read sharing — inviting a collaborator into a shared list stays intentional, and a grant-named handle stays eligible either way.vibes/turn-notify/is built around this: you open a seat, the opponent takes it with their own write, and only then can either player be notified. bodyis the whole message — plain text, 500 characters max. The platform supplies the title (the app's owner and slug) and a link back to the app, so spend the body on what happened and who it concerns, not on re-announcing which app is talking.dedupeKeynames the happening, so one event delivers once however many times the handler runs:"round-4-started","order-42-shipped","game-7-turn-3". The platform keys it per (app, recipient), so two vibes — or two recipients of the same vibe — can never collide on your raw key. A retried handler delivers one notification.
Where it lands, and where it doesn't
It reaches the recipient's bell, inbox, and push notifications, and — if they have turned it on — their email. It does not appear in the weekly digest email, which carries only platform events: new members, comments, remixes, new followers.
The email is off until the recipient asks for it. In their settings, under
Email, "Email me when something happens on my vibes" sends one message per
notification as it happens, so an app that reports something worth acting on —
a form submission, a booking, a request — can reach somebody who is not looking
at the site. That switch is the recipient's alone; nothing in backend.js can
turn it on, and no app code is involved in the send.
Each user also controls a "Messages from a vibe" switch in their settings, and it governs every channel at once — turning it off silences the bell, the push and the email together. So treat a notification as a nudge toward the app, never as the place the information lives. If the only way to learn something is the notification, a user who turned that switch off has a broken app. Put the fact in the database; use the notification to say it's worth a look.
Reach for it when one person needs to know one thing: your turn, your request was approved, your order shipped.
Telling everyone: write it down
Now the other half. The schedule for an event changed and every attendee
should see the new time. There is no notifyEveryone — write the change:
js// backend.js — the refresh tick
export const config = { scheduled: { interval: "15m" } };
export async function scheduled(event, ctx) {
const sessions = await fetchUpstream(ctx); // normalized rows
for (const session of sessions) {
await putIfChanged(ctx, {
_id: `session:${session.id}`,
type: "session",
title: session.title,
stage: session.stage,
startsAt: session.startsAt,
});
}
}
That's the whole broadcast. Every open app is running
jsxconst { docs: sessions } = useLiveQuery("type", { key: "session" });
and the new time is on screen the moment the write commits — for exactly the readers entitled to it, because the access function resolves each reader's audience at read time. Nobody subscribed to a topic. Nobody can subscribe to a topic they shouldn't be in. And a viewer who was offline when it changed sees the current schedule when they come back, which a notification could never guarantee.
The app's own screen is the source of truth. That's exactly why it's the right surface for anything shared.
The two together
The distinction isn't a fork in the road; a real app usually needs both, for different facts about the same event. In the schedule example:
- The refresh reaches everyone through the database — every attendee's view updates, no notification anywhere.
- Only the people who starred a session that moved get a
ctx.notify— one person at a time, each one about their own starred session,dedupeKey'd to the session's new time so a retried tick doesn't buzz them twice.
The test to apply: does this fact belong on the screen, or is it a nudge to
go look at the screen? Facts go in the database. Nudges go through
ctx.notify. A fact delivered only as a notification is a fact that
disappears; a nudge written only into the database is a nudge nobody sees.
Working examples
Two apps in the repo, one for each half:
vibes/turn-notify/— the turn-based case:onChangeon the game database, onectx.notifyto the opponent, per-turndedupeKey.vibes/schedule-watch/— both halves side by side: ascheduledrefresh everyone reads live, plus notifications only to the people who starred a moved session.
New to backends? Start with the
backend.js guide, then come back — this
pattern lives entirely in the onChange and scheduled lanes.