Delivering to offline users with push fallback #
A customer sends a support agent a message. The agent’s dashboard tab is closed, their phone is in their pocket, and the message sits unseen for an hour because the only delivery path was a WebSocket that did not exist. The reverse also happens: the agent is looking at the conversation, the message arrives over the socket, and their phone buzzes with a push notification for the same message a second later. A WebSocket delivers only to connected clients; reaching everyone else requires a second channel — Web Push, APNs or FCM — and a decision, per message and per user, about which channel to use. Getting that decision right is the difference between reliable notifications and noisy, duplicated ones.
Root cause #
WebSockets are a live channel: when no socket is open for a user, a published message has nowhere to go, and the fan-out layer silently drops it. Push notification services are a store-and-forward channel run by the platform (the browser vendor’s push service, Apple, Google): they deliver to devices that are asleep or apps that are closed, but they are slow by comparison (hundreds of milliseconds to minutes), rate-limited, visible to the user as interruptions, and they carry limited payloads.
The hard part is knowing which channel applies at the moment of sending. “Is the user online?” has no crisp answer: a user may have a socket open in a background tab they have not looked at in an hour, a socket that is half-open after their laptop slept, or no socket for three seconds during a reconnect. Sending push whenever there is no socket produces notifications during every reconnect; never sending push when a socket exists misses users who are nominally connected but not present.
Resolution #
Decide by acknowledgement, not by connection state. Deliver every message over the user’s live sockets immediately, and have the client acknowledge messages it has actually displayed (the tab is visible and the conversation is open). Schedule a push for a short grace period later; cancel it when an acknowledgement arrives. If the user has no socket at all, the grace period can be shorter. Record which messages have been notified so a user with several devices is not notified repeatedly, and so reconnecting clients sync missed messages without re-notifying.
import webpush from 'web-push';
import { createClient } from 'redis';
const redis = createClient(); await redis.connect();
const GRACE_WITH_SOCKET_MS = 10_000; // connected but maybe not looking: wait for a "seen" ack
const GRACE_NO_SOCKET_MS = 2_000; // no socket: short delay absorbs a reconnect in progress
const PUSH_DEDUPE_TTL_S = 24 * 3600;
webpush.setVapidDetails('mailto:ops@example.com', process.env.VAPID_PUBLIC!, process.env.VAPID_PRIVATE!);
interface Msg { id: string; userId: string; title: string; body: string; url: string }
export async function deliver(msg: Msg, liveSockets: (userId: string) => number, sendLive: (m: Msg) => void) {
const sockets = liveSockets(msg.userId);
if (sockets > 0) sendLive(msg); // fastest path, always tried
const delay = sockets > 0 ? GRACE_WITH_SOCKET_MS : GRACE_NO_SOCKET_MS;
// Durable schedule (a sorted set as a delay queue) so a restart doesn't lose pending pushes.
await redis.zAdd('push:due', { score: Date.now() + delay, value: JSON.stringify(msg) });
}
// Client acks: the message was displayed to the user. Cancels any pending push.
export async function onSeenAck(userId: string, messageId: string) {
await redis.set(`seen:${userId}:${messageId}`, '1', { EX: PUSH_DEDUPE_TTL_S });
}
// Worker: send pushes that are due, unless seen or already notified.
export async function pushWorker(getSubscriptions: (userId: string) => Promise<webpush.PushSubscription[]>) {
for (;;) {
const due = await redis.zRangeByScore('push:due', 0, Date.now(), { LIMIT: { offset: 0, count: 100 } });
for (const raw of due) {
if ((await redis.zRem('push:due', raw)) === 0) continue; // another worker took it
const msg = JSON.parse(raw) as Msg;
if (await redis.exists(`seen:${msg.userId}:${msg.id}`)) continue; // seen live: skip
const first = await redis.set(`notified:${msg.userId}:${msg.id}`, '1', { NX: true, EX: PUSH_DEDUPE_TTL_S });
if (!first) continue; // already pushed
for (const sub of await getSubscriptions(msg.userId)) {
try {
await webpush.sendNotification(sub, JSON.stringify({ title: msg.title, body: msg.body, url: msg.url, id: msg.id }),
{ TTL: 3600, urgency: 'high', topic: msg.id.slice(0, 32) }); // topic collapses duplicates
} catch (e: any) {
if (e.statusCode === 404 || e.statusCode === 410) await removeSubscription(msg.userId, sub); // expired
}
}
}
await new Promise((r) => setTimeout(r, 500));
}
}
declare function removeSubscription(userId: string, sub: webpush.PushSubscription): Promise<void>;
On the client, send the “seen” acknowledgement only when the message is actually visible — the page is visible and the relevant view is open — not merely when it arrives, otherwise a background tab suppresses the notification the user needed. The client should also acknowledge messages it synced on reconnect, so pushes scheduled during the gap are cancelled. When a push is shown and the user taps it, the app opens, connects, and syncs from its last sequence number, as in resuming WebSocket sessions after reconnect. For native apps, the same worker calls APNs or FCM instead of Web Push; the decision logic is identical.
Edge cases #
Platform requirements. Web Push needs the user’s permission, a service worker, and — on iOS — the site installed to the Home Screen. APNs and FCM need native apps. Some users will never have a push channel; for them, email digests of unseen messages are the last fallback.
Payload limits and privacy. Push payloads are small (around 4 KB) and pass through platform services. Send a short summary and an id, and let the app fetch the full content after opening; for sensitive content, send only “You have a new message”.
Rate limiting and collapsing. Platforms throttle apps that send too many notifications, and users disable noisy ones. Collapse bursts (the topic header in Web Push, apns-collapse-id, FCM collapse_key) so ten messages in a conversation produce one notification with a count.
Verification #
Walk through each row of the matrix with two devices: a visible tab (no push), a background tab (push after the grace period), a closed browser (push quickly), a reconnect in progress (no push), and two devices with one active (no push on either). On the server, export pushes sent, pushes cancelled by acknowledgement, and push failures by status code. A high ratio of pushes to live deliveries for active users means seen-acknowledgements are not arriving; frequent 410 responses mean subscriptions are not being pruned.
# Pending pushes and how overdue the oldest is (seconds).
redis-cli ZCARD push:due
redis-cli ZRANGE push:due 0 0 WITHSCORES | awk 'NR==2 {print (systime()*1000 - $1)/1000 " s overdue"}'
Operational checklist #
FAQ #
How do I notify users who aren’t connected to the WebSocket? #
Use a push channel — Web Push for browsers, APNs or FCM for native apps — triggered when a message has not been acknowledged as seen within a short grace period. The WebSocket remains the fast path for connected users.
How do I avoid sending push notifications to users who are already looking? #
Have the client acknowledge messages when they are actually displayed, and cancel any pending push when that acknowledgement arrives. Deciding by socket existence alone notifies users who are connected but looking elsewhere, or not notifies users with a stale background socket.
Can a service worker keep the WebSocket open instead? #
No. Service workers are stopped when idle, so they cannot hold a WebSocket. They can receive push messages while the page is closed, which is exactly the role the push fallback gives them.
What grace period should I use? #
Long enough to absorb reconnects and quick tab switches — typically 5–15 seconds for connected users and a couple of seconds for users with no socket. Tune it using the ratio of cancelled to sent pushes.
Related #
- At-Least-Once WebSocket Delivery with Acknowledgements — the acknowledgement mechanics.
- WebSockets on Mobile Safari Background Tabs — why mobile users need push.
- Building a WebSocket Presence System with Redis — knowing which sockets exist.
- Outbox Pattern for WebSocket Events — reliable event sources feeding delivery.
Back to Message Delivery Guarantees.