Loading content...
Mobile devices represent a fundamentally different computing environment than desktop browsers or traditional servers. They introduce unique constraints—variable connectivity, battery limitations, storage restrictions, diverse hardware capabilities—that profoundly influence thin-thick architecture decisions.
More than half of global web traffic now comes from mobile devices. For many applications, mobile is the primary platform. Yet mobile's constraints often push architecture decisions in unexpected directions.
Understanding these mobile-specific considerations is essential for architects building applications that serve mobile users effectively. The 'right' architecture for desktop web may be entirely wrong for mobile, and vice versa.
By the end of this page, you will understand how mobile platform constraints shape thin-thick architecture decisions. You'll learn about connectivity variability, battery considerations, storage limitations, platform expectations, and native capabilities—and how each factor influences the architecture you choose.
Mobile connectivity is wildly variable—far more so than desktop scenarios. This variability has profound architectural implications.
Connectivity Variability:
Mobile users experience constant transitions:
This isn't occasional—it's constant. A user on a 30-minute commute might transition between connection states dozens of times.
| Connection Type | Typical Latency | Bandwidth | Reliability |
|---|---|---|---|
| 5G (good signal) | 10-30ms | 100+ Mbps | Excellent |
| 4G LTE | 30-50ms | 10-50 Mbps | Good |
| 4G (poor signal) | 100-300ms | 1-10 Mbps | Variable |
| 3G | 150-400ms | 0.5-3 Mbps | Moderate |
| 2G/Edge | 300-1000ms | 50-200 Kbps | Poor |
| Offline | ∞ | 0 | None |
Architectural Implications:
For mobile, connectivity variability creates pressure toward thick client architecture:
The Two-State Fallacy:
Many developers think of connectivity as binary: online or offline. Real mobile connectivity has many states:
Connectivity States (actual):
- Fast and stable (ideal)
- Fast but unstable (dropping packets)
- Slow but stable (3G rural)
- Slow and unstable (moving vehicle)
- Technically connected but effectively offline (1 bar, requests timeout)
- Actually offline (no signal)
Robust mobile apps account for all these states, not just on/off.
Developers often test on perfect office WiFi or 5G connections. Real users experience 'Hotel WiFi'—technically connected but painfully slow, with unpredictable packet loss. Test your app on a throttled connection simulating 3G or worse. If it's painful, your mobile architecture needs work.
Mobile devices run on batteries. Every operation costs power—and users are acutely aware of battery consumption.
Power Consumption Factors:
Different operations have vastly different power costs:
| Operation | Power Cost | Notes |
|---|---|---|
| Display on | High | Biggest drain, always present |
| Network radio active | High | Radio warm-up is especially expensive |
| GPS active | Very High | Continuous location tracking is costly |
| CPU intensive work | High | Heavy computation drains battery |
| Idle with screen off | Very Low | Baseline power draw |
| Background sync | Moderate | Waking radio periodically |
The network radio is particularly expensive—not just for data transfer, but for the initial wake-up from idle state.
Thin Client Power Impact:
Thin clients keep the network radio active more:
Thick Client Power Optimization:
Thick clients can be more power-efficient:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// Battery-efficient mobile sync patterns class MobileSyncManager { private pendingChanges: Change[] = []; private syncTimeout: NodeJS.Timeout | null = null; // Instead of syncing immediately, batch changes queueChange(change: Change): void { this.pendingChanges.push(change); // Batch changes over a window to reduce radio wake-ups if (!this.syncTimeout) { this.syncTimeout = setTimeout(() => this.sync(), 5000); } } // Sync efficiently - batch all pending changes private async sync(): Promise<void> { this.syncTimeout = null; if (this.pendingChanges.length === 0) return; // Check battery state - defer non-critical sync if low battery const battery = await navigator.getBattery?.(); if (battery && battery.level < 0.15 && !this.hasUrgentChanges()) { // Low battery, defer non-urgent sync this.scheduleDefferredSync(); return; } // Single request with all changes (one radio wake-up) const changes = [...this.pendingChanges]; this.pendingChanges = []; try { await api.batchSync(changes); } catch (error) { // Re-queue on failure this.pendingChanges = [...changes, ...this.pendingChanges]; } } // Use push instead of poll for real-time updates setupRealTimeUpdates(): void { // Push notifications are far more power-efficient than polling // OS handles delivery; no constant network activity // BAD: Polling every 30 seconds // setInterval(() => this.checkForUpdates(), 30000); // GOOD: Push notifications from server messaging.onMessage((message) => { if (message.data.type === 'sync_needed') { this.sync(); } }); } // Use system-provided sync opportunities registerBackgroundSync(): void { // iOS: Background App Refresh // Android: WorkManager // Web: Background Sync API if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) { navigator.serviceWorker.ready.then((registration) => { // Browser will invoke sync when it's convenient (charging, on WiFi, etc.) return registration.sync.register('background-sync'); }); } }}Users notice apps that drain their battery. 'Battery hog' reputation leads to uninstalls. iOS and Android now show battery usage by app—users actively check this. Designing for battery efficiency isn't optional for mobile success; it's a competitive requirement.
Mobile storage differs significantly from desktop and server storage. These differences impact thick client architectures that rely on local data.
Storage Reality on Mobile:
| Platform | Available Storage | Per-App Limits | Eviction Risk |
|---|---|---|---|
| iOS Native App | Device storage | No hard limit (user manages) | Low (user explicitly deletes) |
| Android Native App | Device storage | No hard limit (user manages) | Low (user explicitly deletes) |
| iOS Web (Safari) | Device storage shared | ~1GB quota | Medium (OS can evict) |
| Android Web (Chrome) | Device storage shared | ~60% of free space (up to quota) | Medium (OS can evict) |
| iOS PWA | Device storage shared | ~50-100MB | High (easily evicted) |
| Android PWA | Device storage | Similar to native | Lower than iOS |
Storage Eviction: The Web Difference
For web-based thick clients, storage eviction is a critical concern:
This means web thick clients cannot rely on local data being permanent. Sync to server is always essential, not optional.
Native App Storage:
Native mobile apps have more reliable storage:
This storage reliability is one reason many mobile products choose native apps over web.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// Mobile storage management for thick clients class MobileStorageManager { // Check available quota before heavy writes async checkStorageQuota(): Promise<StorageEstimate> { if ('storage' in navigator && 'estimate' in navigator.storage) { const estimate = await navigator.storage.estimate(); return { quota: estimate.quota ?? 0, usage: estimate.usage ?? 0, available: (estimate.quota ?? 0) - (estimate.usage ?? 0), percentUsed: ((estimate.usage ?? 0) / (estimate.quota ?? 1)) * 100 }; } // Fallback for older browsers return { quota: 50 * 1024 * 1024, usage: 0, available: 50 * 1024 * 1024, percentUsed: 0 }; } // Request persistent storage (reduces eviction risk) async requestPersistence(): Promise<boolean> { if ('storage' in navigator && 'persist' in navigator.storage) { const isPersisted = await navigator.storage.persist(); console.log(`Storage persistence: ${isPersisted ? 'granted' : 'denied'}`); return isPersisted; } return false; } // Intelligent cache prioritization async pruneCache(targetFreeBytes: number): Promise<void> { const db = await openDatabase(); // Get all cached items with metadata const items = await db.table('cached_data').toArray(); // Score items for eviction (lower score = evict first) const scoredItems = items.map(item => ({ ...item, evictionScore: this.calculateEvictionScore(item) })).sort((a, b) => a.evictionScore - b.evictionScore); // Evict lowest-scored items until we have enough space let freedBytes = 0; for (const item of scoredItems) { if (freedBytes >= targetFreeBytes) break; await db.table('cached_data').delete(item.id); freedBytes += item.sizeBytes; } } private calculateEvictionScore(item: CachedItem): number { // Higher score = keep longer let score = 0; // Recency: recently accessed = higher score const hoursSinceAccess = (Date.now() - item.lastAccessedAt) / (1000 * 60 * 60); score += Math.max(0, 100 - hoursSinceAccess); // Frequency: frequently accessed = higher score score += Math.min(item.accessCount * 5, 100); // User-created content = much higher score (don't lose user data!) if (item.isUserGenerated) score += 500; // Pending sync = never evict if (item.pendingSync) score += 1000; return score; } // Detect and handle storage eviction async detectEviction(): Promise<boolean> { const lastKnownVersion = localStorage.getItem('db_version'); const currentDb = await openDatabase(); if (lastKnownVersion && !currentDb.isOpen) { // Database was evicted - need to re-sync from server return true; } return false; }}For mobile web thick clients: never assume local data will persist. Always sync critical data to server. Always gracefully handle scenarios where local data is gone. Test by manually clearing site data and seeing how your app recovers. If it can't recover gracefully, fix that first.
Mobile users have developed strong expectations based on native app experiences. These expectations influence architecture choices.
Native App UX Expectations:
Users trained on native apps expect:
These expectations push strongly toward thick client patterns.
| Expectation | Native App Standard | Web App Reality |
|---|---|---|
| Launch time | < 1 second to usable | 3-5+ seconds (load JS, fetch data) |
| Offline | Expected | Rare, often broken |
| Push notifications | Standard feature | Limited, requires permission |
| Background refresh | OS-supported | Very limited on web |
| Hardware access | Full (camera, GPS, etc.) | Limited, permission-based |
| Performance | 60 FPS expected | Often 30 FPS or worse |
Mobile Usage Patterns:
Mobile usage patterns also differ from desktop:
Implications for Architecture:
Modern web platform capabilities (Service Workers, IndexedDB, Web Push, WebAssembly) are closing the gap with native. A well-built thick client web app can approach native-quality UX. However, 'well-built' requires significant investment in thick client patterns—the gap doesn't close for thin client web apps.
Native mobile platforms offer capabilities impossible or limited in web contexts. These capabilities often require thick client architectures.
Native-Only Capabilities:
| Capability | Native (iOS/Android) | Web |
|---|---|---|
| Background execution | Full (services, tasks) | Very limited (service workers, restrictions) |
| Push notifications | Full system integration | Limited (requires permission, unreliable on iOS) |
| Widgets | Home screen widgets | Not available (except basic PWA icons) |
| Biometric auth | Face ID, fingerprint, native API | WebAuthn (limited, newer) |
| File system | Full access (with permission) | Limited (File System Access API, Chrome only) |
| Bluetooth | Full API | Web Bluetooth (limited, Chrome/Edge) |
| AR/VR | ARKit, ARCore | WebXR (limited) |
| System share | Native share sheet | Web Share API (limited) |
| Camera advanced | Full camera control | Basic via getUserMedia |
| Local notifications | Full scheduling | Limited |
When Native is Required:
Some applications fundamentally require native thick clients:
The React Native/Flutter Middle Ground:
Cross-platform frameworks like React Native and Flutter provide:
These are essentially thick client frameworks—they assume and enable local processing, local storage, and offline capability.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// React Native thick client capabilities example import AsyncStorage from '@react-native-async-storage/async-storage';import { Camera } from 'react-native-camera';import NetInfo from '@react-native-community/netinfo';import BackgroundFetch from 'react-native-background-fetch';import PushNotification from 'react-native-push-notification'; // Native thick client: Robust local storageasync function saveOffline(key: string, data: any): Promise<void> { // AsyncStorage persists reliably (unlike web IndexedDB eviction) await AsyncStorage.setItem(key, JSON.stringify(data));} // Native thick client: Background syncfunction setupBackgroundSync(): void { BackgroundFetch.configure({ minimumFetchInterval: 15, // minutes enableHeadless: true, startOnBoot: true, }, async (taskId) => { // This runs even when app is closed await syncPendingChanges(); BackgroundFetch.finish(taskId); });} // Native thick client: Network awarenessfunction setupNetworkMonitoring(): void { NetInfo.addEventListener(state => { if (state.isConnected && state.isInternetReachable) { // Regained connectivity - sync immediately syncPendingChanges(); } });} // Native thick client: Push notificationsfunction setupPushNotifications(): void { PushNotification.configure({ onNotification: function(notification) { // Handle notification - maybe trigger sync if (notification.data.type === 'new_data') { syncFromServer(); } }, });} // Native thick client: Advanced cameraasync function captureWithProcessing(): Promise<void> { const camera = Camera.current; const photo = await camera.takePictureAsync({ quality: 0.8, exif: true, // Native: Full camera control }); // Local processing before upload const processed = await applyFiltersLocally(photo); await saveOffline('pending_upload', processed); await uploadWhenOnline(processed);}Don't default to native for everything—the development and maintenance overhead is significant. But don't avoid native when requirements demand it. Match platform choice to actual requirements. A note-taking app might be fine as a web PWA; a fitness tracker with Bluetooth heart rate monitors needs native.
Progressive Web Apps (PWAs) represent an attempt to bring thick client capabilities to mobile web. Understanding PWAs is essential for mobile architecture decisions.
What Makes a PWA:
PWAs combine web technologies with native-like capabilities:
| Feature | iOS Safari | Android Chrome | Notes |
|---|---|---|---|
| Install to home screen | ✓ | ✓ | Different UX on each |
| Offline (Service Worker) | ✓ (limited) | ✓ | iOS has restrictions |
| Push notifications | ✓ (iOS 16.4+) | ✓ | iOS added late, limited |
| Background sync | ✗ | ✓ | Not supported on iOS |
| IndexedDB | ✓ (quota limits) | ✓ | iOS evicts aggressively |
| Full-screen mode | ✓ | ✓ | |
| Share target | ✗ | ✓ | Android only |
| Badging API | ✗ | ✓ | App icon badges |
iOS PWA Limitations:
Apple has historically limited PWA capabilities on iOS:
This makes PWAs less viable for thick client patterns on iOS compared to Android.
When PWAs Work Well:
When PWAs Struggle:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// PWA offline strategy for mobile // service-worker.tsconst CACHE_VERSION = 'v1';const APP_SHELL_CACHE = `app-shell-${CACHE_VERSION}`;const DATA_CACHE = `data-${CACHE_VERSION}`; // Precache app shell for instant launchconst APP_SHELL_FILES = [ '/', '/index.html', '/app.js', '/app.css', '/manifest.json', // Critical images and fonts]; self.addEventListener('install', (event) => { event.waitUntil( caches.open(APP_SHELL_CACHE) .then(cache => cache.addAll(APP_SHELL_FILES)) .then(() => self.skipWaiting()) );}); // Serve app shell from cache (instant launch)// Fetch data from network with cache fallbackself.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // App shell: cache-first for instant loading if (url.pathname === '/' || url.pathname.endsWith('.html')) { event.respondWith( caches.match(event.request) .then(cached => cached || fetch(event.request)) ); return; } // API data: network-first with cache fallback if (url.pathname.startsWith('/api/')) { event.respondWith( fetch(event.request) .then(response => { // Cache successful responses if (response.ok) { const clone = response.clone(); caches.open(DATA_CACHE).then(cache => cache.put(event.request, clone)); } return response; }) .catch(() => caches.match(event.request)) ); return; } // Static assets: cache-first event.respondWith( caches.match(event.request) .then(cached => cached || fetch(event.request)) );}); // Handle iOS storage eviction gracefullyasync function handleStorageEviction(): Promise<void> { // Check if our IndexedDB was cleared const db = await openDatabase(); const hasData = await db.table('user_data').count() > 0; if (!hasData && wasLoggedIn()) { // Data was evicted - show message and re-sync showResyncing Message(); await resyncFromServer(); }}PWAs are powerful for certain use cases but don't position them as 'native app replacement' for iOS users. iOS limitations are significant. For Android-primary audiences or simple offline needs, PWAs work great. For full thick client capabilities across platforms, native or cross-platform frameworks may be necessary.
Let's examine patterns that specifically address mobile constraints.
Pattern 1: App Shell + Data Fetching
Load the UI immediately (app shell), then fetch data:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// App Shell pattern for fast mobile launch // 1. App shell loads instantly (cached by service worker)function App() { return ( <AppShell> <Header /> <Navigation /> <MainContent /> {/* Data fetched after shell renders */} <TabBar /> </AppShell> );} // 2. Content loads data after shell is visiblefunction MainContent() { // Show cached data immediately if available const cachedData = useCachedData('feed'); // Then fetch fresh data in background const { data, isLoading } = useQuery('feed', fetchFeed, { initialData: cachedData, staleTime: 30000, // Cached data is valid for 30s }); // User sees content instantly (cached), then it refreshes return ( <div> {cachedData && <FeedList items={cachedData} />} {isLoading && !cachedData && <FeedSkeleton />} {data && <FeedList items={data} />} </div> );} // 3. Prefetch likely-needed datafunction Navigation() { const prefetch = usePrefetch(); return ( <nav> <NavItem to="/feed" onHover={() => prefetch('feed')} /> <NavItem to="/profile" onHover={() => prefetch('profile')} /> </nav> );}Pattern 2: Offline-First with Sync
Write locally first, sync when possible:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Offline-first pattern for mobile apps class OfflineFirstClient { private db: LocalDatabase; private syncManager: SyncManager; async createItem(item: Item): Promise<Item> { // 1. Generate local ID const localItem = { ...item, id: generateLocalId(), syncStatus: 'pending', createdAt: Date.now(), }; // 2. Save locally FIRST - this is instant await this.db.items.add(localItem); // 3. Update UI immediately (optimistic) this.notifyListeners('item:created', localItem); // 4. Queue for sync this.syncManager.queue({ type: 'create', entity: 'item', data: localItem, }); // 5. Attempt sync if online if (navigator.onLine) { this.syncManager.syncNow(); } // User sees result instantly, regardless of network return localItem; } async getItems(): Promise<Item[]> { // Always read from local database // This is instant - no network wait return this.db.items.toArray(); } async syncFromServer(): Promise<void> { if (!navigator.onLine) return; const lastSyncTime = await this.db.getSyncTimestamp(); const changes = await api.getChangesSince(lastSyncTime); for (const change of changes) { if (change.type === 'upsert') { await this.db.items.put(change.data); } else if (change.type === 'delete') { await this.db.items.delete(change.id); } } await this.db.setSyncTimestamp(Date.now()); this.notifyListeners('sync:complete'); }}Pattern 3: Smart Sync Scheduling
Sync when conditions are favorable:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// Smart sync: sync when conditions are favorable class SmartSyncManager { async shouldSync(): Promise<{ shouldSync: boolean; reason: string; defer?: boolean; }> { // Check network quality const connection = navigator.connection; // Don't sync on metered connections unless urgent if (connection?.saveData) { return { shouldSync: false, reason: 'Data saver enabled' }; } // Prefer WiFi for large syncs if (connection?.type === 'cellular' && this.hasLargeSync()) { return { shouldSync: false, reason: 'Large sync deferred to WiFi', defer: true }; } // Check battery const battery = await navigator.getBattery?.(); if (battery && battery.level < 0.20 && !battery.charging) { return { shouldSync: false, reason: 'Low battery', defer: true }; } // Check if we have urgent changes const urgentChanges = await this.getUrgentChanges(); if (urgentChanges.length > 0) { return { shouldSync: true, reason: 'Urgent changes pending' }; } // Check time since last sync const lastSync = await this.getLastSyncTime(); const hoursSinceSync = (Date.now() - lastSync) / (1000 * 60 * 60); if (hoursSinceSync > 1) { return { shouldSync: true, reason: 'Stale data' }; } return { shouldSync: false, reason: 'No sync needed' }; } async scheduleOptimalSync(): Promise<void> { // Use the Periodic Background Sync API where available if ('periodicSync' in ServiceWorkerRegistration.prototype) { const registration = await navigator.serviceWorker.ready; try { await registration.periodicSync.register('content-sync', { minInterval: 12 * 60 * 60 * 1000, // 12 hours }); } catch { // Fall back to manual scheduling this.scheduleManualSync(); } } }}For true mobile-first design, assume intermittent connectivity. Design your architecture as if offline is the normal state and network is a bonus. This mindset produces robust mobile apps that delight users regardless of their connection quality.
With mobile-specific factors understood, let's build a decision framework for mobile architecture.
Step 1: Assess Mobile Requirements
Answer these questions:
| Requirement | Thin Web | PWA (Thick Web) | Cross-Platform (RN/Flutter) | Native |
|---|---|---|---|---|
| Always online only | ✓ Best | ✓ | ⚡ Overkill | ⚡ Overkill |
| Occasional offline | ✗ | ✓ (Android focused) | ✓ Best | ✓ |
| Primary offline | ✗ | ⚠ Limited | ✓ | ✓ Best |
| < 100ms interactions | ✗ | ✓ | ✓ | ✓ Best |
| Native capabilities | ✗ | ⚠ Limited | ✓ | ✓ Best |
| iOS + Android | ✓ | ⚠ iOS limitations | ✓ Best | ⚡ 2x work |
| Development speed | ✓ Best | ✓ | ✓ | ✗ Slower |
| Development cost | ✓ Lowest | ✓ | ✓ Moderate | ✗ Highest |
Decision Paths:
Path A: Mobile Web (Thin/Responsive)
Path B: PWA (Thick Web)
Path C: Cross-Platform (React Native/Flutter)
Path D: Native (Swift/Kotlin)
Building excellent mobile experiences is more expensive than desktop web. Accept this. Budget for it. The alternative—a poor mobile experience—costs more in lost users and reputation than doing it right. Mobile users are unforgiving; they have alternatives one tap away.
We've explored how mobile platform constraints shape thin-thick architecture decisions. Let's consolidate the essential takeaways:
What's next:
We'll explore Progressive Web Apps in greater depth—understanding how PWAs represent a specific point on the thin-thick spectrum that attempts to bring native-like capabilities to the web platform.
You now understand how mobile's unique constraints—connectivity, battery, storage, user expectations, and platform capabilities—shape architecture decisions. This knowledge enables you to make informed choices for mobile applications rather than defaulting to familiar patterns that may not fit mobile's reality.