Mobile apps have unique constraints that desktop applications don't face. Limited bandwidth, spotty connections, battery drain, and data caps mean that APIs designed for web browsers often fall short when powering mobile experiences. If you've ever watched a loading spinner for 30 seconds on a slow connection, you know what I'm talking about.
Designing APIs specifically for mobile isn't just about making things faster—it's about making them work reliably in challenging conditions. This guide covers practical strategies for building mobile-optimized REST APIs, from bandwidth optimization to offline support, using real examples from the Modern PetStore API.
Understanding Mobile Constraints
Before diving into solutions, let's understand what makes mobile different:
Bandwidth limitations: Mobile networks are slower and less reliable than WiFi. A 4G connection might drop to 3G or even 2G in certain areas.
Data caps: Users pay for data. Every unnecessary byte costs them money.
Battery consumption: Network requests drain batteries. Radio activation, data transmission, and JSON parsing all consume power.
Intermittent connectivity: Mobile users move through tunnels, elevators, and areas with poor coverage.
Device capabilities: Mobile devices have less processing power and memory than desktops.
These constraints require a fundamentally different approach to API design.
Bandwidth Optimization Strategies
1. Minimize Payload Size
The first rule of mobile API design: send less data. Here's a typical "desktop-first" API response:
GET /api/pets/123
{
"id": 123,
"name": "Fluffy",
"species": "cat",
"breed": "Persian",
"age": 3,
"weight": 4.5,
"color": "white",
"owner": {
"id": 456,
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"phone": "+1-555-0123",
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zipCode": "62701",
"country": "USA"
},
"registrationDate": "2023-01-15T10:30:00Z",
"membershipLevel": "premium"
},
"medicalHistory": [...],
"vaccinations": [...],
"appointments": [...]
}
This response includes everything, whether the mobile app needs it or not. A mobile-optimized version uses field selection:
GET /api/pets/123?fields=id,name,species,breed,age
{
"id": 123,
"name": "Fluffy",
"species": "cat",
"breed": "Persian",
"age": 3
}
Implement field selection in your API:
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/api/pets/<int:pet_id>')
def get_pet(pet_id):
pet = get_pet_from_database(pet_id)
# Check for field selection
fields = request.args.get('fields')
if fields:
requested_fields = fields.split(',')
filtered_pet = {k: v for k, v in pet.items() if k in requested_fields}
return jsonify(filtered_pet)
return jsonify(pet)
2. Use Compression
Enable gzip compression on your API server. This typically reduces payload size by 60-80%:
from flask import Flask
from flask_compress import Compress
app = Flask(__name__)
Compress(app) # Automatically compresses responses
On the client side, ensure your HTTP library supports compression:
// iOS example
var request = URLRequest(url: url)
request.setValue("gzip, deflate", forHTTPHeaderField: "Accept-Encoding")
3. Implement Pagination with Cursors
Traditional offset-based pagination is inefficient for mobile:
GET /api/pets?offset=100&limit=20 // Skip 100 records every time
Use cursor-based pagination instead:
GET /api/pets?cursor=eyJpZCI6MTIzfQ&limit=20
Implementation:
import base64
import json
@app.route('/api/pets')
def list_pets():
limit = int(request.args.get('limit', 20))
cursor = request.args.get('cursor')
if cursor:
# Decode cursor to get last seen ID
decoded = json.loads(base64.b64decode(cursor))
last_id = decoded['id']
pets = Pet.query.filter(Pet.id > last_id).limit(limit).all()
else:
pets = Pet.query.limit(limit).all()
# Create next cursor
if pets:
next_cursor = base64.b64encode(
json.dumps({'id': pets[-1].id}).encode()
).decode()
else:
next_cursor = None
return jsonify({
'data': [pet.to_dict() for pet in pets],
'nextCursor': next_cursor
})
Delta Sync: Only Send What Changed
Delta sync is crucial for mobile apps. Instead of downloading the entire dataset every time, only sync changes since the last update.
Basic Delta Sync Implementation
Add a lastModified timestamp to your resources:
from datetime import datetime
from sqlalchemy import Column, DateTime
class Pet(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100))
last_modified = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
Implement a delta endpoint:
@app.route('/api/pets/delta')
def get_pets_delta():
since = request.args.get('since') # ISO 8601 timestamp
if since:
since_dt = datetime.fromisoformat(since.replace('Z', '+00:00'))
modified_pets = Pet.query.filter(Pet.last_modified > since_dt).all()
else:
# First sync - return everything
modified_pets = Pet.query.all()
return jsonify({
'data': [pet.to_dict() for pet in modified_pets],
'timestamp': datetime.utcnow().isoformat() + 'Z'
})
Client usage:
// iOS example
func syncPets() {
let lastSync = UserDefaults.standard.string(forKey: "lastPetSync") ?? ""
let url = URL(string: "https://api.petstore.com/pets/delta?since=\(lastSync)")!
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else { return }
let result = try? JSONDecoder().decode(DeltaResponse.self, from: data)
// Update local database with changes
updateLocalDatabase(result.data)
// Save new sync timestamp
UserDefaults.standard.set(result.timestamp, forKey: "lastPetSync")
}.resume()
}
Handling Deletions
Delta sync needs to track deletions too. Add a soft delete mechanism:
class Pet(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100))
deleted = db.Column(db.Boolean, default=False)
last_modified = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@app.route('/api/pets/delta')
def get_pets_delta():
since = request.args.get('since')
since_dt = datetime.fromisoformat(since.replace('Z', '+00:00'))
# Include deleted items in delta
modified_pets = Pet.query.filter(Pet.last_modified > since_dt).all()
return jsonify({
'data': [pet.to_dict() for pet in modified_pets],
'timestamp': datetime.utcnow().isoformat() + 'Z'
})
Offline Support and Conflict Resolution
Mobile apps must work offline. This requires local storage and conflict resolution strategies.
Optimistic Updates
Allow users to make changes immediately, then sync in the background:
func updatePetName(petId: Int, newName: String) {
// 1. Update local database immediately
localDB.updatePet(id: petId, name: newName)
// 2. Update UI
refreshUI()
// 3. Queue API request
let request = PendingRequest(
method: "PATCH",
url: "/api/pets/\(petId)",
body: ["name": newName],
localId: petId
)
requestQueue.add(request)
// 4. Process queue when online
processQueueWhenOnline()
}
Conflict Resolution
When offline changes conflict with server changes, you need a resolution strategy. The simplest is "last write wins":
@app.route('/api/pets/<int:pet_id>', methods=['PATCH'])
def update_pet(pet_id):
pet = Pet.query.get_or_404(pet_id)
data = request.json
# Check if client has stale data
client_version = request.headers.get('If-Match')
if client_version and client_version != str(pet.last_modified.timestamp()):
return jsonify({
'error': 'Conflict',
'message': 'Pet was modified by another client',
'currentVersion': pet.to_dict()
}), 409
# Update pet
pet.name = data.get('name', pet.name)
pet.last_modified = datetime.utcnow()
db.session.commit()
return jsonify(pet.to_dict())
Client handling:
func syncPendingChanges() {
for request in requestQueue.all() {
let response = try await apiClient.send(request)
if response.statusCode == 409 {
// Conflict detected
let conflict = try JSONDecoder().decode(Conflict.self, from: response.data)
// Show conflict resolution UI
showConflictResolution(
local: request.body,
remote: conflict.currentVersion
)
} else {
// Success - remove from queue
requestQueue.remove(request)
}
}
}
Mobile-Specific Endpoints
Create endpoints optimized for common mobile screens:
Dashboard Endpoint
Instead of multiple requests:
GET /api/pets
GET /api/appointments/upcoming
GET /api/notifications/unread
Create a single dashboard endpoint:
@app.route('/api/mobile/dashboard')
def mobile_dashboard():
user_id = get_current_user_id()
return jsonify({
'pets': Pet.query.filter_by(owner_id=user_id).limit(5).all(),
'upcomingAppointments': Appointment.query.filter(
Appointment.owner_id == user_id,
Appointment.date > datetime.utcnow()
).limit(3).all(),
'unreadNotifications': Notification.query.filter_by(
user_id=user_id,
read=False
).count()
})
This reduces 3 round trips to 1, saving time and battery.
Push Notifications
Push notifications keep users informed without constant polling.
Server-Side Implementation
from firebase_admin import messaging
@app.route('/api/appointments', methods=['POST'])
def create_appointment():
appointment = Appointment(**request.json)
db.session.add(appointment)
db.session.commit()
# Send push notification
message = messaging.Message(
notification=messaging.Notification(
title='Appointment Confirmed',
body=f'Your appointment for {appointment.pet_name} is scheduled'
),
token=get_user_device_token(appointment.owner_id),
data={
'appointmentId': str(appointment.id),
'type': 'appointment_created'
}
)
messaging.send(message)
return jsonify(appointment.to_dict()), 201
Client-Side Handling
// iOS example
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
guard let appointmentId = userInfo["appointmentId"] as? String else { return }
// Update local database
syncAppointment(id: appointmentId)
// Update UI if app is active
if application.applicationState == .active {
refreshAppointmentsList()
}
}
Battery and Data Efficiency
Batch Requests
Reduce radio activation by batching requests:
@app.route('/api/batch', methods=['POST'])
def batch_requests():
requests = request.json['requests']
responses = []
for req in requests:
try:
if req['method'] == 'GET':
result = handle_get(req['url'])
elif req['method'] == 'POST':
result = handle_post(req['url'], req['body'])
responses.append({
'id': req['id'],
'status': 200,
'body': result
})
except Exception as e:
responses.append({
'id': req['id'],
'status': 500,
'error': str(e)
})
return jsonify({'responses': responses})
Client usage:
let batchRequest = [
["id": "1", "method": "GET", "url": "/api/pets"],
["id": "2", "method": "GET", "url": "/api/appointments"],
["id": "3", "method": "POST", "url": "/api/notifications/read", "body": ["ids": [1, 2, 3]]]
]
apiClient.post("/api/batch", body: ["requests": batchRequest])
Smart Sync Intervals
Adjust sync frequency based on battery and network conditions:
func determineSyncInterval() -> TimeInterval {
let batteryLevel = UIDevice.current.batteryLevel
let networkType = getNetworkType()
switch (batteryLevel, networkType) {
case (0.8...1.0, .wifi):
return 60 // 1 minute on WiFi with good battery
case (0.5...0.8, .wifi):
return 300 // 5 minutes
case (_, .cellular):
return 600 // 10 minutes on cellular
case (0...0.2, _):
return 1800 // 30 minutes on low battery
default:
return 300
}
}
Image Optimization
Images are bandwidth killers. Serve appropriately sized images:
@app.route('/api/pets/<int:pet_id>/photo')
def get_pet_photo(pet_id):
pet = Pet.query.get_or_404(pet_id)
size = request.args.get('size', 'medium')
size_map = {
'thumbnail': (100, 100),
'small': (300, 300),
'medium': (600, 600),
'large': (1200, 1200)
}
dimensions = size_map.get(size, (600, 600))
# Resize and compress image
image_url = resize_image(pet.photo_url, dimensions, quality=80)
return jsonify({'url': image_url})
Client request:
// Request thumbnail for list view
let url = "https://api.petstore.com/pets/123/photo?size=thumbnail"
// Request large for detail view
let url = "https://api.petstore.com/pets/123/photo?size=large"
Monitoring Mobile Performance
Track mobile-specific metrics:
from flask import request
import time
@app.before_request
def before_request():
request.start_time = time.time()
@app.after_request
def after_request(response):
duration = time.time() - request.start_time
# Log mobile-specific metrics
user_agent = request.headers.get('User-Agent', '')
is_mobile = 'Mobile' in user_agent or 'Android' in user_agent
if is_mobile:
log_metric('mobile_api_latency', duration, {
'endpoint': request.path,
'method': request.method,
'status': response.status_code,
'response_size': len(response.data)
})
return response
Conclusion
Designing APIs for mobile requires thinking differently about bandwidth, battery, and connectivity. The strategies covered here—field selection, delta sync, offline support, mobile-specific endpoints, and push notifications—will help you build APIs that feel fast and reliable even on slow connections.
Start with bandwidth optimization and delta sync. These provide immediate benefits with minimal complexity. Then layer in offline support and push notifications as your app matures.
Remember: every byte counts, every request drains battery, and every user might be on a spotty connection. Design with these constraints in mind, and your mobile users will thank you.