Backend APIs for Mobile Apps

Real-time Sync and Push Notifications for Mobile Apps on Domain India VPS

By Domain India Team · DomainIndia EngineeringPublished 11 min read
Knowledge base article
Contents (14 sections)

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.

Key takeaways

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

TransportApp stateBest forBattery cost
WebSocketForegroundChat, presence, live cursorsHigher (connection stays open)
Server-Sent Events (SSE)ForegroundSimple one-way server-to-client updatesMedium
FCM / APNs pushBackground or closedNotifications, silent refresh triggersLow (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)

javascript
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:

javascript
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:

dart
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.

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 = () => {
      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

  1. Create a Firebase project.
    Use the Firebase console and add your Android and iOS apps to it.
  2. Add the config files.
    Download google-services.json (Android) and GoogleService-Info.plist (iOS) into your app projects.
  3. 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.
  4. Install the Firebase SDK in the app.
    On launch, ask for notification permission and get the FCM registration token.
  5. Store the token on your backend.
    Save it in a user_devices table, and send it again whenever it changes.

Backend (Node.js, firebase-admin)

javascript
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:

javascript
{
  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:

javascript
// 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:

javascript
// 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

WebSocket drains the battery
Keep the connection open only while the app is in the foreground, and close it when the app goes to the background.
Reconnect storms
Without backoff and jitter, every phone reconnects at once after a restart. Add both.
Large push payloads
APNs and FCM cap payloads at about 4 KB. Send an ID and let the app fetch the rest.
Stale FCM tokens
Tokens change. Send the current token on every launch and delete ones FCM reports as unregistered.
BadDeviceToken from APNs
Usually a sandbox and production mix-up. Check the app build and the APNs setup in Firebase.
Relying on silent push
iOS throttles it and Android battery savers delay it. Design for missed deliveries.

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.

Run your real-time backend on a VPS

Full root access for WebSocket servers, Redis and push workers, with nginx in front.

View VPS plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app