Backend APIs for Mobile Apps

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

By Domain India Team · DomainIndia EngineeringPublished · Updated 6 min read
Knowledge base article
Contents (13 sections)
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

TransportApp stateBest forBattery cost
WebSocketForegroundChat, presence, live cursorsHigh (connection open)
Server-Sent Events (SSE)ForegroundOne-way server → client, simpleMedium
FCM / APNs pushBackground/killedNotifications, silent triggersNone (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).

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

WS drains battery
keep connection open only while app is foregrounded. Close on background.
No exponential backoff on reconnect
thundering herd after server restart. Add jitter + backoff.
Huge push notification payload
iOS caps at 4 KB. Send an ID; let the app fetch full data.
FCM token not refreshed
tokens rotate. Always send latest token on app launch.
APNs rejected — "BadDeviceToken"
wrong environment (sandbox vs production). Use appropriate APNs key.
Silent push abused
Apple throttles; Android's battery optimizer kills it. Design for unreliable delivery.

FAQ

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.

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.

Do I need both WS and push?

Yes for most apps. WS for active users, push for re-engagement. They complement.

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

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

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
Real-time Sync & Push Notifications for Mobile Apps