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 1000+ 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
Recommended Reading
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 |
Viprasol in Action
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 →
- Mobile App Development Company
- API Development Company
- Real-Time Application Development
- Software Scalability
External Resources
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 1000+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement.
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.