Mobile App Backend Development: APIs, Push Notifications, and Offline Sync
Mobile app backend development in 2026 — REST vs GraphQL for mobile, push notification architecture, offline-first sync patterns, and production backend design
Mobile App Backend Development: APIs, Push Notifications, and Offline Sync
Mobile backends have unique requirements that web backends often handle poorly: intermittent connectivity, battery constraints, platform-specific push notification routing, background sync, and the need to support multiple API versions simultaneously (because users don't always update their apps).
This guide covers the complete mobile backend stack — API design for mobile clients, push notification architecture, offline-first sync patterns, and the versioning strategies that prevent old app versions from breaking.
BaaS vs. Custom Backend
Before building, consider managed Backend-as-a-Service options:
| Option | Best For | Monthly Cost (at scale) | Limitations |
|---|---|---|---|
| Firebase (Google) | Rapid prototyping, real-time apps | $25–$500 | Vendor lock-in, limited querying |
| Supabase | PostgreSQL-based apps, open source | $25–$599 | Younger ecosystem |
| AWS Amplify | AWS-native apps | $25–$300 | Complex config |
| Custom (Node.js/Python) | Complex business logic, compliance | $100–$1,000 (infra) | Higher build cost |
Rule: Use Firebase or Supabase for apps where real-time sync, simple data models, and fast iteration are priorities. Build custom for apps with complex business logic, compliance requirements (HIPAA, PCI), or multi-platform requirements (mobile + web + third-party integrations).
API Design for Mobile Clients
Mobile APIs have different constraints than web APIs:
Minimize round trips. A web browser on WiFi handles 10 API calls per page load fine. A mobile client on 3G in rural India cannot. Design endpoints that return everything the screen needs in one call.
// ❌ BAD: 4 separate calls to render a profile screen
// GET /users/123
// GET /users/123/posts
// GET /users/123/followers
// GET /users/123/following
// ✅ GOOD: Single call returns everything the profile screen needs
app.get('/api/v1/users/:id/profile', authenticate, async (req, res) => {
const userId = req.params.id;
const [user, posts, followerCount, followingCount, isFollowing] = await Promise.all([
db('users').where({ id: userId }).select('id', 'username', 'bio', 'avatar_url').first(),
db('posts').where({ user_id: userId }).orderBy('created_at', 'desc').limit(12),
db('follows').where({ following_id: userId }).count('id as count').first(),
db('follows').where({ follower_id: userId }).count('id as count').first(),
db('follows').where({ follower_id: req.user.sub, following_id: userId }).first(),
]);
res.json({
user,
recentPosts: posts,
followerCount: Number(followerCount?.count ?? 0),
followingCount: Number(followingCount?.count ?? 0),
isFollowing: !!isFollowing,
});
});
Pagination for lists. Mobile screens are small; infinite scroll is the pattern. Use cursor-based pagination (not offset) for consistent results with real-time data:
// Cursor-based pagination — stable across real-time inserts/deletes
app.get('/api/v1/feed', authenticate, async (req, res) => {
const { cursor, limit = 20 } = req.query;
const pageLimit = Math.min(Number(limit), 50); // Cap at 50
let query = db('posts')
.join('users', 'posts.user_id', 'users.id')
.select(
'posts.id', 'posts.content', 'posts.created_at',
'users.username', 'users.avatar_url'
)
.orderBy('posts.created_at', 'desc')
.limit(pageLimit + 1); // Fetch one extra to determine hasMore
if (cursor) {
// Cursor is a base64-encoded timestamp
const cursorTime = new Date(Buffer.from(cursor as string, 'base64').toString());
query = query.where('posts.created_at', '<', cursorTime);
}
const items = await query;
const hasMore = items.length > pageLimit;
const results = hasMore ? items.slice(0, pageLimit) : items;
const nextCursor = hasMore
? Buffer.from(results[results.length - 1].created_at.toISOString()).toString('base64')
: null;
res.json({ items: results, nextCursor, hasMore });
});
Response compression. Enable gzip/brotli — mobile clients benefit significantly from smaller payloads:
import compression from 'compression';
app.use(compression({
level: 6,
threshold: 1024, // Only compress responses > 1KB
}));
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
API Versioning for Mobile
Mobile apps can't force users to update. You must maintain backward compatibility for old versions, sometimes for 12–24 months.
// Version-aware routing
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// Or version via Accept header (more RESTful)
app.use('/api/users', (req, res, next) => {
const version = req.headers['accept-version'] ?? 'v1';
req.apiVersion = version;
next();
});
// In controllers, branch on version
async function getUserProfile(req: Request, res: Response) {
const user = await fetchUser(req.params.id);
if (req.apiVersion === 'v2') {
// v2 includes social graph counts inline
res.json(formatUserV2(user));
} else {
// v1 — legacy format
res.json(formatUserV1(user));
}
}
Sunset policy: When deprecating a version, return Deprecation and Sunset headers so clients can detect the timeline:
// Add to deprecated API versions
app.use('/api/v1', (req, res, next) => {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 01 Oct 2026 00:00:00 GMT');
res.setHeader('Link', '</api/v2>; rel="successor-version"');
next();
});
Push Notifications Architecture
Push notifications route through platform-specific services:
- iOS → Apple Push Notification Service (APNs)
- Android → Firebase Cloud Messaging (FCM)
Use a backend service that abstracts both:
// Device token registration — store with platform info
app.post('/api/devices/register', authenticate, async (req, res) => {
const { token, platform, deviceId } = req.body;
// platform: 'ios' | 'android'
await db('device_tokens').insert({
user_id: req.user.sub,
token,
platform,
device_id: deviceId,
created_at: new Date(),
}).onConflict('device_id').merge(['token', 'updated_at']); // Update token if rotated
res.json({ registered: true });
});
// Push notification service — abstracts APNs and FCM
import admin from 'firebase-admin';
// Initialize Firebase Admin (handles both APNs via Firebase and FCM)
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY!.replace(/\\n/g, '\n'),
}),
});
interface PushNotification {
title: string;
body: string;
data?: Record<string, string>;
badge?: number; // iOS only
sound?: string;
}
async function sendPushToUser(userId: string, notification: PushNotification): Promise<void> {
const tokens = await db('device_tokens')
.where({ user_id: userId })
.select('token', 'platform');
if (tokens.length === 0) return;
const messages = tokens.map(({ token, platform }) => ({
token,
notification: {
title: notification.title,
body: notification.body,
},
data: notification.data,
apns: platform === 'ios' ? {
payload: {
aps: {
badge: notification.badge,
sound: notification.sound ?? 'default',
},
},
} : undefined,
android: platform === 'android' ? {
priority: 'high' as const,
notification: { sound: 'default' },
} : undefined,
}));
const response = await admin.messaging().sendEach(messages);
// Remove invalid/expired tokens
const expiredTokens = response.responses
.map((r, i) => (!r.success && isExpiredTokenError(r.error)) ? tokens[i].token : null)
.filter(Boolean);
if (expiredTokens.length > 0) {
await db('device_tokens').whereIn('token', expiredTokens).delete();
}
}
function isExpiredTokenError(error: admin.FirebaseError | undefined): boolean {
return error?.code === 'messaging/registration-token-not-registered' ||
error?.code === 'messaging/invalid-registration-token';
}
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Offline-First Sync
Users expect apps to work without connectivity. Offline-first means the app reads from and writes to a local database, then syncs when connectivity returns.
Sync Pattern
┌─────────────────────────────────────────────────┐
│ Mobile Client │
│ Local DB (SQLite / WatermelonDB / Realm) │
│ ↓ (always reads/writes locally) │
│ Sync Engine │
│ - Queue local changes │
│ - Detect connectivity │
│ - Push changes to server on reconnect │
│ - Pull server changes, merge conflicts │
└─────────────────────────────────────────────────┘
↕ (sync on connectivity)
┌─────────────────────────────────────────────────┐
│ Backend │
│ Sync API (/api/sync) │
│ Conflict Resolution Logic │
│ Change Log Table │
└─────────────────────────────────────────────────┘
// Backend sync endpoint — handles delta sync
app.post('/api/v1/sync', authenticate, async (req, res) => {
const { lastSyncedAt, changes } = req.body;
const userId = req.user.sub;
// 1. Apply client changes to server (with conflict detection)
const conflicts: SyncConflict[] = [];
for (const change of changes) {
const existing = await db('items').where({ id: change.id }).first();
if (existing && existing.updated_at > new Date(change.clientUpdatedAt)) {
// Server version is newer — conflict!
// Last-write-wins: use server version (or implement more complex merge)
conflicts.push({ clientChange: change, serverRecord: existing });
} else {
// Apply client change
await db('items').insert({
id: change.id,
user_id: userId,
...change.data,
updated_at: new Date(),
}).onConflict('id').merge();
}
}
// 2. Return server changes since last sync
const serverChanges = await db('items')
.where({ user_id: userId })
.where('updated_at', '>', new Date(lastSyncedAt ?? 0))
.select('*');
res.json({
serverChanges,
conflicts,
syncedAt: new Date().toISOString(),
});
});
Cost to Build a Mobile Backend
| Scope | Timeline | Investment |
|---|---|---|
| Simple CRUD backend (BaaS-based) | 2–4 weeks | $5,000–$15,000 |
| Custom REST API (no real-time) | 4–8 weeks | $15,000–$35,000 |
| Full backend (auth + push + offline sync) | 8–16 weeks | $35,000–$80,000 |
| Backend + admin dashboard + analytics | 16–24 weeks | $80,000–$200,000 |
Working With Viprasol
We build mobile backends for React Native, Flutter, and native iOS/Android apps — from simple API layers through real-time, offline-first systems with push notification infrastructure.
→ Mobile backend consultation →
→ Mobile App Development →
→ Web Development Services →
See Also
- Mobile App Development Company
- API Development Company
- Real-Time Application Development
- Software Scalability
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.