# Real-time Sync and Push Notifications for Mobile Apps on DomainIndia VPS
TL;DR
Mobile apps need three real-time patterns: WebSocket for in-app live updates (chat, presence), Server-Sent Events or long-polling for server-pushed state, and FCM/APNs for out-of-app notifications. This guide covers all three on a DomainIndia VPS with a Node.js/Python backend.
## Three kinds of real-time for mobile
| Transport | App state | Best for | Battery cost |
| WebSocket | Foreground | Chat, presence, live cursors | High (connection open) |
| Server-Sent Events (SSE) | Foreground | One-way server → client, simple | Medium |
| FCM / APNs push | Background/killed | Notifications, silent triggers | None (OS-level) |
A typical app uses all three:
- WebSocket while user is actively in the chat screen
- FCM push when user closes the app
## Part 1 — WebSocket for in-app real-time
### Server (Node.js with `ws`)
```javascript
import { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';
const wss = new WebSocketServer({ port: 4001 });
// Auth on connect
wss.on('connection', (ws, req) => {
const url = new URL(req.url, 'http://x');
const token = url.searchParams.get('token');
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
ws.userId = payload.sub;
} catch {
ws.close(4001, 'Unauthorized');
return;
}
console.log(`User ${ws.userId} connected`);
ws.on('message', async (data) => {
const msg = JSON.parse(data);
if (msg.type === 'chat:send') {
await saveMessage(ws.userId, msg.roomId, msg.text);
broadcast(msg.roomId, {
type: 'chat:new',
roomId: msg.roomId,
author: ws.userId,
text: msg.text,
timestamp: Date.now(),
});
}
});
ws.on('close', () => console.log(`User ${ws.userId} disconnected`));
});
function broadcast(roomId, payload) {
wss.clients.forEach((client) => {
if (client.readyState === 1 && client.rooms?.has(roomId)) {
client.send(JSON.stringify(payload));
}
});
}
```
Front behind nginx with WS proxy (see our [GraphQL Subscriptions article](https://domainindia.com/support/kb/graphql-subscriptions-websockets-vps)).
### Client (React Native / Flutter)
**React Native:**
```javascript
const ws = new WebSocket(`wss://api.yourcompany.com/ws?token=${authToken}`);
ws.onopen = () => console.log('Connected');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'chat:new') updateChatUI(msg);
};
ws.onerror = (err) => console.warn('WS error', err);
ws.onclose = () => scheduleReconnect();
```
**Flutter:**
```dart
import 'package:web_socket_channel/web_socket_channel.dart';
final channel = WebSocketChannel.connect(
Uri.parse('wss://api.yourcompany.com/ws?token=$authToken'),
);
channel.stream.listen((data) {
final msg = jsonDecode(data);
if (msg['type'] == 'chat:new') updateUI(msg);
});
```
## Part 2 — Reconnection & offline buffer
Mobile networks drop constantly. Your client code must handle it.
```javascript
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 = () => {
setTimeout(() => this.connect(), this.retryMs);
this.retryMs = Math.min(this.retryMs * 1.5, 30000); // backoff to 30s max
};
}
send(data) {
const msg = JSON.stringify(data);
if (this.ws.readyState === 1) this.ws.send(msg);
else this.buffer.push(msg); // queue until reconnect
}
}
```
## Part 3 — Push notifications (FCM covers iOS + Android)
### Setup
1
Create Firebase project at console.firebase.google.com
2
Add iOS + Android apps to the project
3
Download google-services.json (Android) and GoogleService-Info.plist (iOS)
4
In iOS: Apple Developer account → Certificates → Create APNs Auth Key → upload to Firebase
5
Install Firebase SDK in your app
6
On app launch, request notification permission and get FCM token
7
Send token to your backend, store in user_devices table
### Backend (Node.js)
```javascript
import admin from 'firebase-admin';
import serviceAccount from './firebase-service-account.json' assert { type: 'json' };
admin.initializeApp({
credential: admin.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, // custom key-value map
android: {
priority: 'high',
notification: { channelId: 'default' },
},
apns: {
headers: { 'apns-priority': '10' },
payload: { aps: { sound: 'default', badge: 1 } },
},
}));
const batch = await admin.messaging().sendEach(messages);
// Clean up invalid tokens
batch.responses.forEach((resp, i) => {
if (!resp.success && resp.error.code === 'messaging/registration-token-not-registered') {
db.device.delete({ where: { id: devices[i].id } });
}
});
}
// Usage:
await sendToUser(user.id, {
title: 'New order received',
body: 'Order #1234 from Rajesh',
data: { orderId: '1234', type: 'new_order' },
});
```
### Silent push (background trigger)
Android / iOS allow "silent" data-only messages that trigger background code without showing a notification. Useful for "refresh feed now" or "mark read".
```javascript
{
token: deviceToken,
data: { sync: 'true', type: 'refresh_feed' },
android: { priority: 'high' },
apns: {
headers: { 'apns-push-type': 'background', 'apns-priority': '5' },
payload: { aps: { 'content-available': 1 } },
},
}
```
Apple throttles silent push aggressively — expect 2–3 per hour max, often less.
## Part 4 — Offline sync pattern
For "read offline, write online" apps (note-taking, to-do):
**Client side (SQLite local DB):**
- Every action writes to local DB first (optimistic)
- Background worker tries to sync to server
- Conflict resolution: last-write-wins or CRDT
**Server side:**
```javascript
// Pull changes since client's last sync
app.get('/sync/pull', async (req, res) => {
const since = req.query.since || '0';
const changes = await db.item.findMany({
where: {
userId: req.user.id,
updatedAt: { gt: new Date(parseInt(since)) },
},
});
res.json({ changes, now: Date.now() });
});
// Push client's pending changes
app.post('/sync/push', async (req, res) => {
const { changes } = req.body;
for (const change of changes) {
// Conflict resolution: compare updatedAt
const existing = await db.item.findUnique({ where: { id: change.id } });
if (!existing || existing.updatedAt < change.updatedAt) {
await db.item.upsert({
where: { id: change.id },
update: change,
create: change,
});
}
}
res.json({ synced: changes.length });
});
```
Libraries that automate this:
- **PowerSync** (Postgres → SQLite sync)
- **Realm** (built-in sync, paid for cloud)
- **WatermelonDB** (DIY with helpers)
## Presence (who's online)
Write presence to Redis with TTL:
```javascript
ws.on('open', () => redis.setex(`presence:${userId}`, 60, '1'));
ws.on('close', () => redis.del(`presence:${userId}`));
// Heartbeat every 30s to refresh TTL
setInterval(() => redis.setex(`presence:${userId}`, 60, '1'), 30000);
// Who's online?
const online = await redis.keys('presence:*');
```
For many users, use Redis sorted sets by timestamp — more efficient than individual keys.
## Common pitfalls
## FAQ
Q
Can I host WebSocket backend on shared DomainIndia?
Limited — cPanel's Node.js App + Passenger has rough WS support. For production mobile real-time, use VPS.
Q
FCM or OneSignal?
FCM is free, lower-level. OneSignal wraps FCM + APNs with delivery analytics, segmentation, A/B tests. Start with FCM; migrate to OneSignal if you need marketing features.
Q
Do I need both WS and push?
Yes for most apps. WS for active users, push for re-engagement. They complement.
Q
What about Server-Sent Events (SSE)?
SSE is one-way (server→client), simpler, survives most firewalls better than WS. Good for dashboards, notifications. Use WS for bidirectional (chat).
Q
Battery impact of heartbeats?
30s heartbeat is the sweet spot — keeps NAT table alive, minimal battery. Lower = more battery; higher = risk of dropped connection unnoticed.
Run WebSocket + push backend on a DomainIndia VPS.
View plans