Chat, live order tracking and "someone replied" alerts all need data to reach the phone without the user pulling to refresh. Mobile apps use three tools for this: WebSockets while the app is open, push notifications when it isn't, and an offline sync layer for patchy networks. This guide covers all three with a Node.js backend, and where each part can run on Domain India.
Use a WebSocket (or Server-Sent Events) connection for live updates while the app is in the foreground, Firebase Cloud Messaging (FCM, which also delivers to iOS through APNs) for notifications when it is closed, and a pull/push sync API for offline use. WebSocket servers are long-running processes, so they belong on a VPS; they are stopped on shared hosting and not supported on the App Platform.
1. Three kinds of real-time for mobile
| Transport | App state | Best for | Battery cost |
|---|---|---|---|
| WebSocket | Foreground | Chat, presence, live cursors | Higher (connection stays open) |
| Server-Sent Events (SSE) | Foreground | Simple one-way server-to-client updates | Medium |
| FCM / APNs push | Background or closed | Notifications, silent refresh triggers | Low (handled by the OS) |
A typical chat or delivery app uses two of them together:
- a WebSocket while the user is on the chat or tracking screen;
- a push notification once the user has left the app.
2. WebSockets for in-app real-time
Server (Node.js with ws)
import { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';
const wss = new WebSocketServer({ port: 4001, host: '127.0.0.1' }); // nginx proxies to it
wss.on('connection', (ws, req) => {
// Auth on connect. A query-string token can end up in proxy logs:
// use a short-lived token issued just for the socket.
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
try {
ws.userId = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }).sub;
} catch {
ws.close(4001, 'Unauthorized');
return;
}
ws.rooms = new Set();
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', async (data) => {
let msg;
try { msg = JSON.parse(data); } catch { return; }
if (msg.type === 'room:join' && await canJoin(ws.userId, msg.roomId)) {
ws.rooms.add(msg.roomId);
}
if (msg.type === 'chat:send' && ws.rooms.has(msg.roomId)) {
const saved = await saveMessage(ws.userId, msg.roomId, msg.text);
broadcast(msg.roomId, { type: 'chat:new', ...saved });
}
});
});
function broadcast(roomId, payload) {
const text = JSON.stringify(payload);
for (const client of wss.clients) {
if (client.readyState === 1 && client.rooms?.has(roomId)) client.send(text);
}
}
// Drop dead connections (phones that lost signal without closing)
setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; }
ws.isAlive = false;
ws.ping();
}
}, 30000);Put nginx in front with a WebSocket proxy and TLS, as shown in our GraphQL subscriptions article.
Client
React Native:
const ws = new WebSocket(`wss://api.yourcompany.com/ws?token=${socketToken}`);
ws.onopen = () => ws.send(JSON.stringify({ type: 'room:join', roomId }));
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'chat:new') updateChatUI(msg);
};
ws.onclose = () => scheduleReconnect();Flutter:
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
final channel = WebSocketChannel.connect(
Uri.parse('wss://api.yourcompany.com/ws?token=$socketToken'),
);
channel.stream.listen(
(data) {
final msg = jsonDecode(data);
if (msg['type'] == 'chat:new') updateUI(msg);
},
onDone: scheduleReconnect,
onError: (_) => scheduleReconnect(),
);3. Reconnection and an offline buffer
Mobile networks drop all the time, so the client must reconnect on its own, with backoff and jitter so thousands of phones don't reconnect in the same second after a server restart.
class ResilientWS {
constructor(url) {
this.url = url;
this.retryMs = 1000;
this.buffer = [];
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.retryMs = 1000;
this.buffer.forEach(m => this.ws.send(m));
this.buffer = [];
};
this.ws.onclose = () => {
const jitter = Math.random() * 1000;
setTimeout(() => this.connect(), this.retryMs + jitter);
this.retryMs = Math.min(this.retryMs * 1.5, 30000); // back off to 30 s at most
};
}
send(data) {
const msg = JSON.stringify(data);
if (this.ws.readyState === 1) this.ws.send(msg);
else this.buffer.push(msg); // queue until reconnected
}
}After a reconnect, fetch anything missed (for example "messages after ID 1234") over HTTP; don't assume the socket delivered everything.
4. Push notifications with FCM (Android and iOS)
Setup
- Create a Firebase project.Use the Firebase console and add your Android and iOS apps to it.
- Add the config files.Download
google-services.json(Android) andGoogleService-Info.plist(iOS) into your app projects. - Connect APNs.In your Apple Developer account, create an APNs authentication key under Keys and upload it in the Firebase project's Cloud Messaging settings.
- Install the Firebase SDK in the app.On launch, ask for notification permission and get the FCM registration token.
- Store the token on your backend.Save it in a
user_devicestable, and send it again whenever it changes.
Backend (Node.js, firebase-admin)
import { readFileSync } from 'node:fs';
import { initializeApp, cert } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
// Keep the service-account file outside your web root and out of Git
const serviceAccount = JSON.parse(readFileSync(process.env.FIREBASE_SA_PATH, 'utf8'));
initializeApp({ credential: cert(serviceAccount) });
async function sendToUser(userId, { title, body, data }) {
const devices = await db.device.findMany({ where: { userId } });
if (devices.length === 0) return;
const messages = devices.map(d => ({
token: d.fcmToken,
notification: { title, body },
data, // string values only
android: { priority: 'high', notification: { channelId: 'default' } },
apns: { payload: { aps: { sound: 'default' } } },
}));
const result = await getMessaging().sendEach(messages);
// Remove tokens that are no longer valid
await Promise.all(result.responses.map((r, i) =>
!r.success && r.error?.code === 'messaging/registration-token-not-registered'
? db.device.delete({ where: { id: devices[i].id } })
: null
));
}
await sendToUser(user.id, {
title: 'New order received',
body: 'Order #1234 from Rajesh',
data: { orderId: '1234', type: 'new_order' },
});Silent push (background trigger)
Data-only messages can wake the app to refresh data without showing anything:
{
token: deviceToken,
data: { type: 'refresh_feed' },
android: { priority: 'normal' },
apns: {
headers: { 'apns-push-type': 'background', 'apns-priority': '5' },
payload: { aps: { 'content-available': 1 } },
},
}Apple asks developers to send no more than two or three background pushes an hour and may deliver fewer; Android battery savers can delay them too. Treat silent push as a hint, never as the only way data arrives.
5. Offline sync
For apps that must work offline (notes, to-do lists, field data):
Client side (local SQLite):
- write every change to the local database first, so the UI updates at once;
- a background task sends pending changes to the server when online;
- resolve conflicts by "last write wins" or, for collaborative editing, with CRDTs.
Server side:
// Pull changes since the client's last sync
app.get('/sync/pull', async (req, res) => {
const since = new Date(Number(req.query.since) || 0);
const changes = await db.item.findMany({
where: { userId: req.user.id, updatedAt: { gt: since } },
});
res.json({ changes, now: Date.now() });
});
// Push the client's pending changes
app.post('/sync/push', async (req, res) => {
let synced = 0;
for (const c of req.body.changes ?? []) {
const existing = await db.item.findUnique({ where: { id: c.id } });
if (existing && existing.userId !== req.user.id) continue; // not theirs
if (existing && existing.updatedAt >= new Date(c.updatedAt)) continue; // server is newer
const data = { title: c.title, done: c.done, updatedAt: new Date(c.updatedAt), userId: req.user.id };
await db.item.upsert({ where: { id: c.id }, update: data, create: { id: c.id, ...data } });
synced++;
}
res.json({ synced });
});Copy only the fields you expect from each change (never pass the client's object straight to the database), and check ownership on every item.
Libraries that do much of this for you include PowerSync (PostgreSQL to SQLite sync) and WatermelonDB (a local database with sync helpers you connect to your own API). MongoDB's Atlas Device Sync (Realm sync) has been discontinued, so don't start new projects on it.
6. Presence: who's online
Keep a "last seen" timestamp per user and treat anyone seen in the last minute as online. With Redis on a VPS, a sorted set does this efficiently:
// On connect and on every heartbeat (e.g. each pong)
await redis.zAdd('presence', { score: Date.now(), value: String(ws.userId) });
// Who is online? Anyone seen in the last 60 seconds
const online = await redis.zRangeByScore('presence', Date.now() - 60000, '+inf');
// Tidy up now and then
await redis.zRemRangeByScore('presence', 0, Date.now() - 60000);Never use KEYS presence:* in production: it scans the whole database and blocks Redis while it does. Without Redis, a last_seen column in PostgreSQL works for small apps.
7. Running this on Domain India
- WebSocket or SSE server: VPS. Long-running processes such as WebSocket servers are stopped on shared hosting, and the App Platform does not support WebSockets. Run the socket server on a Domain India VPS behind nginx. The VPS is self-managed: you install, update and back it up yourself.
- Redis: not available on shared hosting or the App Platform. Install it on your VPS if you need it.
- REST, sync and push-sending APIs: ordinary request/response endpoints. They can run on the App Platform (Node.js is detected automatically, and PostgreSQL is included) or with Setup Node.js App on cPanel and DirectAdmin shared hosting; see deploying a Node.js app on shared hosting. Only the socket server itself needs the VPS.
- Background jobs such as sending push to many users: cron on shared hosting runs at most every 4 minutes and workers are stopped, so large fan-outs belong on the VPS.
8. Common pitfalls
FAQ
Can I host a WebSocket backend on Domain India shared hosting?
No. Long-running processes such as WebSocket servers are stopped on shared hosting, and the App Platform does not support WebSockets. Use a Domain India VPS for the socket server; your REST API can stay on shared hosting or the App Platform.
FCM or OneSignal?
FCM is free and lower-level. OneSignal is built on FCM and APNs and adds delivery analytics, segmentation and A/B tests. Start with FCM and move to a service like OneSignal if you need marketing features.
Do I need both WebSockets and push?
For most chat and delivery apps, yes. The WebSocket serves users who have the app open; push reaches users who don't. They complement each other.
What about Server-Sent Events (SSE)?
SSE is one-way, from server to client, over ordinary HTTP. It is simpler than WebSockets and often passes proxies more easily, which suits dashboards and live feeds. Use WebSockets when the client also sends messages, as in chat. Like WebSockets, SSE keeps a long-lived connection, so run it on a VPS.
How often should I send heartbeats?
Every 25-30 seconds is a common choice. It keeps mobile network and proxy connections from timing out while costing little battery. Much shorter wastes battery; much longer means dead connections go unnoticed for longer.
Where should I store the Firebase service-account key?
On the server only, in a file outside the web root or in an environment variable, and never in Git or in the mobile app. Anyone with that key can send notifications to all your users.
Ready to add real-time to your app? Run the socket server on a VPS, host your REST API on the App Platform, and read building backend APIs for mobile apps for the rest of the stack. Questions about a plan? Open a support ticket.
Full root access for WebSocket servers, Redis and push workers, with nginx in front.
View VPS plans