Free lesson · Mobile System Design Interviews
Data Storage on Mobile - Choosing the Right Home for Your Data
Where do you keep your stuff at home? Your daily essentials sit on the kitchen counter. Important documents go in the filing cabinet. Sentimental items rest on shelves. That old bicycle? The attic.
Mobile apps face the same problem. Different data needs different storage. Let's learn where to keep what.
The House Analogy: Understanding Mobile Storage
Imagine your app is a house:
- Kitchen Counter = Quick access to daily items (app settings, user preferences, tiny pieces of data)
- Filing Cabinet = Organized collections with relationships (structured data, lists, queries)
- Safe = Valuable, sensitive items (passwords, tokens, encryption keys)
- Kitchen Table = Temporary workspace (data you're actively using right now)
- Attic/Garage = Bulky items you keep but rarely access (large files, videos, documents)
Let's explore each.
Key-Value Storage: The Kitchen Counter
SharedPreferences (Android) / UserDefaults (iOS)
This is the fastest, easiest storage. You store simple key-value pairs: "isDarkModeEnabled" → true or "userId" → 12345.
Perfect For
- User preferences (theme, language, notifications on/off)
- Small configuration values
- App settings
- Last-used credentials (though not sensitive ones!)
- Feature flags
Not Good For
- Large amounts of data
- Structured data with relationships
- Anything sensitive (without encryption)
Quick Example
| Kotlin (SharedPreferences) | Swift (UserDefaults) |
|---|---|
val prefs = context.getSharedPreferences("app_prefs", MODE_PRIVATE)<br/><br/>// Write<br/>prefs.edit().putString("username", "alice").apply()<br/>prefs.edit().putInt("userId", 42).apply()<br/><br/>// Read<br/>val username = prefs.getString("username", "")<br/>val userId = prefs.getInt("userId", -1) |
let defaults = UserDefaults.standard<br/><br/>// Write<br/>defaults.set("alice", forKey: "username")<br/>defaults.set(42, forKey: "userId")<br/><br/>// Read<br/>let username = defaults.string(forKey: "username") ?? ""<br/>let userId = defaults.integer(forKey: "userId") |
💡 Tip: Both platforms are simple and data persists across app launches. Perfect for quick lookups.
⚠️ Watch out: Don't store large strings, arrays, or objects here. SharedPreferences/UserDefaults have performance limits as data grows.
Relational Databases: The Filing Cabinet
SQLite (both platforms) / Room (Android) / Core Data (iOS)
This is organized, queryable storage. You define tables with columns, relationships between tables, and query them with SQL or higher-level frameworks.
Perfect For
- Lists of items (contacts, messages, posts)
- Data with relationships ("A user has many posts, and each post has many comments")
- Large datasets you need to search or filter
- Offline-first apps that sync with a server
The Structure
Think of a filing cabinet with labeled drawers. Each drawer is a table. Each file in the drawer is a row. Properties of that file are columns.
[Users Table]
| ID | Name | Email | CreatedAt |
|----|---------|-----------------|------------|
| 1 | Alice | alice@email.com | 2024-01-01 |
| 2 | Bob | bob@email.com | 2024-01-02 |
[Posts Table]
| ID | UserId | Title | Content |
|----|--------|--------------|------------|
| 101| 1 | My first post| Hello world |
| 102| 1 | Second post | ... |
Room (Android) Example
// Define your entity (a single row in the table)
@Entity
data class User(
@PrimaryKey val id: Int,
val name: String,
val email: String
)
// Define a data access object (DAO) - how you talk to the database
@Dao
interface UserDao {
@Insert
suspend fun insertUser(user: User)
@Query("SELECT * FROM user WHERE id = :userId")
suspend fun getUserById(userId: Int): User
@Query("SELECT * FROM user")
suspend fun getAllUsers(): List<User>
@Delete
suspend fun deleteUser(user: User)
}
// The database
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
Core Data (iOS) Example
// Define your entity
@Entity
final class User {
@Attribute(.unique) var id: Int
var name: String
var email: String
init(id: Int, name: String, email: String) {
self.id = id
self.name = name
self.email = email
}
}
// Access the data via ModelContext
@Model
final class User {
var id: Int
var name: String
var email: String
}
// In your code:
// Create
let newUser = User(id: 1, name: "Alice", email: "alice@email.com")
modelContext.insert(newUser)
// Read
let fetchDescriptor = FetchDescriptor<User>()
let allUsers = try? modelContext.fetch(fetchDescriptor)
// Update
user.name = "Alice Smith"
try? modelContext.save()
// Delete
modelContext.delete(user)
💡 Tip: Both Room and Core Data handle the database details for you. You focus on your data model, not SQL queries.
Why Use a Database Instead of JSON Files?
Because databases are fast at searching. If you have 10,000 messages and want to find all from today, a database query takes milliseconds. Loading a JSON file with 10,000 messages and searching it would be slow.
Databases also handle relationships easily. A message belongs to a user, and a user has many messages. Databases make this natural.
File Storage: The Attic
Files stored directly on disk
Use this for images, videos, documents, PDFs, or anything too large for a database.
Perfect For
- Photos and videos from the camera
- Downloaded PDFs, documents
- Cache files (temporary data you can re-download)
- Large binary data
Not Good For
- Searchable data (you'd have to parse the entire file)
- Data with relationships
- Anything you need to query
Simple Example
| Kotlin | Swift |
|---|---|
val file = File(context.filesDir, "mydata.json")<br/><br/>// Write<br/>file.writeText(jsonString)<br/><br/>// Read<br/>val content = file.readText() |
let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]<br/>let fileURL = docDir.appendingPathComponent("mydata.json")<br/><br/>// Write<br/>try jsonString.write(to: fileURL, atomically: true, encoding: .utf8)<br/><br/>// Read<br/>let content = try String(contentsOf: fileURL) |
Secure Storage: The Safe
Keychain (iOS) / EncryptedSharedPreferences or Keystore (Android)
For passwords, authentication tokens, and API keys, use secure storage. Data is encrypted by the OS and protected by the device lock.
Perfect For
- Passwords and PINs
- Access tokens and refresh tokens
- API keys
- Encryption keys
- Any personally identifiable information (PII)
Never Store Sensitive Data in SharedPreferences or UserDefaults!
If you store an access token in plain SharedPreferences, anyone with access to the device (or a backup) can steal it.
Simple Example
| Kotlin (Keystore) | Swift (Keychain) |
|---|---|
val keyStore = KeyStore.getInstance("AndroidKeyStore")<br/>keyStore.load(null)<br/><br/>// Writing (abstracted - use a library!)<br/>// Encrypt the token before storing<br/>val encryptedToken = cipher.doFinal(token.toByteArray())<br/>keyStore.setEntry(...)<br/><br/>// Reading<br/>val encryptedData = keyStore.getEntry(...)<br/>val token = cipher.doFinal(encryptedData) |
import Security<br/><br/>// Simple helper to store<br/>let query: [String: Any] = [<br/> kSecClass as String: kSecClassGenericPassword,<br/> kSecAttrAccount as String: "userToken",<br/> kSecValueData as String: token.data(using: .utf8)<br/>]<br/>SecItemAdd(query as CFDictionary, nil)<br/><br/>// Reading<br/>var result: AnyObject?<br/>SecItemCopyMatching(query, &result)<br/>let token = String(data: result, encoding: .utf8)` |
💡 Tip: Don't implement encryption yourself. Use the OS's secure storage—it's battle-tested and handles key management.
⚠️ Watch out: These APIs are complex. Use a library or wrapper to simplify them. iOS 15+ has
@Keychainproperty wrapper, and Android has Jetpack Security libraries.
In-Memory Cache: The Kitchen Table
Data you're actively working with stays in memory (RAM). It's super fast but disappears when your app closes.
Perfect For
- Data currently displayed on screen
- Recently fetched data you're using again soon
- Processed data you don't want to recompute
Simple Example
// Android with ViewModel (data survives configuration changes)
class UserViewModel : ViewModel() {
private var _cachedUser: User? = null
fun getUser(id: Int) {
if (_cachedUser?.id == id) {
// Return cached version
return _cachedUser
}
// Fetch from database or network
_cachedUser = fetchFromDatabase(id)
}
}
// iOS with a simple variable
class UserViewModel: ObservableObject {
private var cachedUser: User?
func getUser(id: Int) {
if cachedUser?.id == id {
// Return cached version
return cachedUser
}
// Fetch from database or network
cachedUser = fetchFromDatabase(id)
}
}
📝 Note: ViewModels are perfect for in-memory caching because they live as long as your screen is visible but are destroyed when the screen closes.
Storage Comparison: Quick Reference
| Type | Best For | Capacity | Speed | Survives Restart? | Queryable? | Sensitive Data? |
|---|---|---|---|---|---|---|
| Key-Value | Settings, flags | ~MB | ⚡️⚡️⚡️ | ✅ Yes | ❌ No | ⚠️ No (not encrypted) |
| Database | Lists, relationships | ~100 MB+ | ⚡️⚡️ | ✅ Yes | ✅ Yes | ⚠️ No (not encrypted) |
| Files | Images, videos, docs | Unlimited | ⚡️ | ✅ Yes | ❌ No | ⚠️ No (not encrypted) |
| Secure Storage | Tokens, passwords | Small | ⚡️ | ✅ Yes | ❌ No | ✅ Yes (encrypted) |
| In-Memory | Current session | RAM available | ⚡️⚡️⚡️ | ❌ No | ✅ Yes | ⚠️ Depends |
Decision Tree: Where Should This Data Live?
Is it sensitive (passwords, tokens, API keys)?
├─ YES → Use Keychain/Secure Storage
└─ NO → Continue
Is it a single small value (app theme, language)?
├─ YES → Use Key-Value Storage
└─ NO → Continue
Is it a large file (image, video, document)?
├─ YES → Use File Storage
└─ NO → Continue
Is it a collection of items with relationships?
├─ YES → Use a Database
└─ NO → Use Key-Value or Files
Do you need it only while the app is running?
├─ YES → Keep in memory
└─ NO → Persist to disk
Common Storage Mistakes
❌ Storing Too Much in Key-Value
// Bad
val prefs = context.getSharedPreferences("app", MODE_PRIVATE)
prefs.edit().putString("allUserPosts", largeJsonString).apply()
SharedPreferences is for small, simple values. If you have a list, use a database.
❌ Not Encrypting Sensitive Data
// Bad
prefs.edit().putString("authToken", token).apply()
// Good
secureStorage.saveToken(token) // Encrypted automatically
❌ Querying Large Files Instead of Using a Database
// Bad
val jsonContent = File("posts.json").readText()
val posts = parseJson(jsonContent)
val filtered = posts.filter { it.userId == 42 }
// Good
val filtered = database.postDao().getPostsByUser(42)
❌ Keeping Large Objects in Memory Unnecessarily
// Bad
class MyViewModel : ViewModel() {
val allUsers = repository.getAllUsers() // Loaded immediately, kept in memory
}
// Good
class MyViewModel : ViewModel() {
val allUsers = repository.getAllUsers.asLiveData() // Loaded on demand
}
Practical Scenario: A Chat App
Let's trace where data lives in a real chat app:
- User preferences (dark mode, notifications) → Key-Value Storage
- Auth token → Secure Storage
- List of conversations → Database (need to search, filter, sort)
- Message text → Database
- Message images → File Storage (or database if small enough)
- Currently displayed messages → In-Memory Cache (for fast scrolling)
- Downloaded media files → File Storage
This layered approach gives you security, performance, and scalability.
Interview Tips: Storage Questions
They Might Ask:
-
"How would you store user authentication tokens?"
- Answer: "In secure storage. On iOS, I'd use Keychain. On Android, I'd use EncryptedSharedPreferences or the Jetpack Security library. It's automatically encrypted and protected by the device lock."
-
"Where would you cache API responses?"
- Answer: "For temporary caching, in-memory in the ViewModel while the screen is visible. For persistent caching, I'd use a database so I can query it. It depends on whether I need to search the cache or just keep recent data available."
-
"How would you handle 1 million user objects?"
- Answer: "Never load all 1 million into memory. I'd use a database with pagination—load 20 at a time as the user scrolls. Or use a lazy loading strategy with LiveData/Flow."
What NOT to Say:
❌ "I just use SharedPreferences for everything" ❌ "I store passwords in plain text" ❌ "I load all data into memory when the app starts"
What TO Say:
✅ "I choose storage based on the data type: key-value for settings, databases for searchable collections, secure storage for sensitive data, and files for large assets. I also think about lifecycle—what survives the app being closed?"
✅ "I avoid loading large datasets into memory. Instead, I use pagination or lazy loading to load only what's visible on screen."
Key Takeaways
- Different data needs different homes—choose the right storage type
- Secure storage is mandatory for passwords, tokens, and API keys
- Databases excel at relationships—don't try to replace them with files
- Keep memory usage low by not caching unnecessary data
- Encryption is automatic when you use the OS's secure storage—use it!
- Think about persistence—what should survive when the app closes?
Choose wisely, and your app will be fast, secure, and efficient.
How Databases Work Under the Hood: B-Trees, Indexes, and WAL
SQLite powers both Android and iOS. Understanding how it works helps you use it better.
The B-Tree: How Data is Organized
Databases don't store data in a simple array. They use B-Trees—a tree structure optimized for disk storage.
Think of a filing cabinet:
- Each drawer holds multiple folders (a node)
- Folders are organized alphabetically
- You can quickly narrow down which drawer, then which folder
Root Node (Index)
├─ Names A-D → Drawer 1
├─ Names E-H → Drawer 2
└─ Names I-Z → Drawer 3
Drawer 1 contains:
├─ Alice (id: 1)
├─ Bob (id: 5)
├─ Charlie (id: 3)
└─ Donna (id: 8)
Why this matters:
- Finding "Bob" doesn't require reading all 1 million users
- Database skips entire sections: "Not in A-D range, check next drawer"
- This is logarithmic search: O(log n) instead of O(n)
Indexes: The Book's Table of Contents
Without an index, finding all posts by user 42 requires reading every single post. Ouch.
With an index:
Index on user_id:
├─ user_id=1 → [post 101, post 105, post 108]
├─ user_id=42 → [post 203, post 204, post 222] ← Found instantly!
└─ user_id=999 → [post 500]
An index is another B-Tree that points back to the main data.
// ✅ Good: Add an index for frequently searched columns
@Entity(indices = [Index("userId")])
data class Post(
@PrimaryKey val id: Int,
val userId: Int, // Indexed for fast lookups
val content: String
)
@Dao
interface PostDao {
// This query will be FAST because userId is indexed
@Query("SELECT * FROM post WHERE userId = :userId")
suspend fun getPostsByUser(userId: Int): List<Post>
}
Cost of indexes:
- Take up extra storage
- Slow down inserts/updates (index must be updated too)
- Use them for columns you search on frequently, not everything
💡 Tip: Index your foreign keys and search columns. Don't index every column.
Write-Ahead Logging (WAL): Crash Safety
What happens if your app crashes mid-transaction?
Transaction:
1. Deduct 00 from Alice's account ← Done
2. Add 00 to Bob's account ← CRASH!
3. Commit ← Never happens
Money is gone! One person lost it, but nobody gained it.
Write-Ahead Logging (WAL) prevents this:
WAL Mode:
1. Before writing anything, log the change to disk
"About to deduct 00 from Alice"
"About to add 00 to Bob"
2. Write to main database
3. Commit
If crash happens between step 1 and 3:
→ On recovery, look at log
→ See incomplete transaction
→ Rollback everything
→ No data loss!
SQLite uses WAL by default. It's one reason SQLite is so reliable.
// SQLite uses WAL automatically
// Your code just needs to handle transactions properly
@Transaction
suspend fun transferMoney(from: String, to: String, amount: Double) {
database.withTransaction {
val fromAccount = getAccount(from)
val toAccount = getAccount(to)
updateAccount(fromAccount.copy(balance = fromAccount.balance - amount))
updateAccount(toAccount.copy(balance = toAccount.balance + amount))
// If anything fails here, EVERYTHING is rolled back
}
}
SQL vs NoSQL: The Theory
Both can work on mobile, but they solve different problems.
ACID Properties: SQL's Strength
ACID = Atomicity, Consistency, Isolation, Durability
Atomicity: A transaction is all-or-nothing.
Transfer money: Deduct from A AND add to B, or neither.
Not possible: Deduct from A without adding to B.
Consistency: Database rules are always enforced.
If a user can't have negative balance, database prevents it.
No way to create invalid data.
Isolation: Two transactions don't interfere.
Person A reading account balance while Person B is transferring money.
A sees consistent snapshot, not "halfway through" state.
Durability: Data persists after commit.
After writing to database and confirming success,
data won't be lost even if app crashes immediately.
SQL databases (SQLite, Room) provide strong ACID guarantees. This matters when data consistency is critical: financial transactions, medical records, legal documents.
CAP Theorem for Mobile (Simplified)
CAP theorem says in distributed systems, you can have 2 of 3:
- Consistency - All copies of data are identical
- Availability - System responds to requests
- Partition tolerance - System works if network splits
On mobile, we're offline-first. We choose Availability + Partition tolerance, trading off Consistency.
Online scenario:
┌──────────────┐ ┌──────────┐
│ Mobile App │──Network────│ Server │
│ (local copy) │ │ (source) │
└──────────────┘ └──────────┘
↓
Data syncs perfectly
User always sees latest
Offline scenario:
┌──────────────┐ ┌──────────┐
│ Mobile App │━━OFFLINE━━ |│ Server │
│ (local copy) │ │ (source) │
└──────────────┘ └──────────┘
↓
App uses local copy (stale but available)
Server has latest (unavailable to app)
When network returns, they sync (eventually consistent)
Real-world example: Your chat app shows a message as "sent" locally (availability). Seconds later, server confirms (consistency). If network is slow, you see "sent" while server hasn't received it yet. This is OK for chat.
For a banking app, you might want to wait for server confirmation before showing "sent."
When Consistency Matters
Some data requires strong consistency:
- Financial transactions - Can't lose money
- Inventory counts - Can't oversell
- User authentication - Can't let invalid logins through
Some data can be eventual:
- Social media likes - If your like shows up 2 seconds late, OK
- Chat messages - "Read" status can sync later
- User preferences - Theme sync can be eventual
Data Serialization: JSON vs Protocol Buffers vs FlatBuffers
Your data travels between device and server. How you encode it affects speed, size, and battery.
JSON: Human-Readable
{
"id": 42,
"name": "Alice",
"email": "alice@example.com",
"posts": [
{ "id": 101, "content": "Hello" },
{ "id": 102, "content": "World" }
]
}
Pros:
- Human-readable (debug easily)
- Universal (every language supports it)
- Simple to use
Cons:
- Large (all those quotes and colons add size)
- Slow to parse (text parsing is slower than binary)
- Bandwidth-heavy (important on 3G connections)
Size estimate for 1,000 user objects:
- JSON: ~500 KB
Protocol Buffers: Compact Binary
Google's format used internally. Clients and server must use the same schema.
message User {
int32 id = 1;
string name = 2;
string email = 3;
repeated Post posts = 4;
}
message Post {
int32 id = 1;
string content = 2;
}
Encodes as binary, much smaller.
Pros:
- Very compact (~40% of JSON size)
- Fast to parse (binary parsing is quick)
- Strongly typed (schema mismatch caught early)
- Backward compatible (can add fields safely)
Cons:
- Not human-readable (must use tools to debug)
- Requires code generation
- Steeper learning curve
Size estimate for 1,000 user objects:
- Protocol Buffers: ~200 KB (60% smaller!)
FlatBuffers: Zero-Copy Parsing
Facebook's format. The name says it: buffers are flat (not nested trees), so you read data without parsing/copying.
Traditional parsing:
Binary data → Parse → Objects in memory → Read field
FlatBuffers:
Binary data → Read field directly (pointer arithmetic)
Pros:
- Ultra-fast (no parsing overhead)
- Compact (similar to Protocol Buffers)
- Access any field instantly
Cons:
- Less mature than Protocol Buffers
- Harder to debug
- Requires special libraries
Size: Similar to Protocol Buffers
Decision Tree: Which Format?
Is this a quick API or a large dataset?
├─ Small API (< 100 KB) → JSON (simplicity wins)
└─ Large dataset (> 1 MB) → Protocol Buffers or FlatBuffers
Can you afford code generation?
├─ Yes → Protocol Buffers (mature, battle-tested)
├─ No → JSON
Do you need human-debuggable format?
├─ Yes → JSON
└─ No → Protocol Buffers or FlatBuffers
Is performance critical (game, real-time)?
├─ Yes → FlatBuffers (zero-copy)
└─ No → Protocol Buffers
Practical Example
// JSON: Easy but large
val json = """
{
"users": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
]
}
""".trimIndent() // ~100 bytes
// Protocol Buffers: Compact, requires schema
message UserList {
repeated User users = 1;
}
message User {
int32 id = 1;
string name = 2;
}
// Same data: ~30 bytes
// In code with gRPC:
val userList = UserList.parseFrom(binaryData)
Storage Decision Framework: A Flowchart
When you need to store something, work through this:
START: Need to store data?
Is it sensitive (passwords, tokens)?
├─ YES → Secure Storage (Keychain/EncryptedSharedPreferences)
│ └─ Stop here
└─ NO → Continue
Is it a single small value (< 1 KB)?
├─ YES → Key-Value Storage (SharedPreferences/UserDefaults)
│ └─ Stop here
└─ NO → Continue
Is it a large file (image, video, PDF)?
├─ YES → File Storage
│ └─ Stop here
└─ NO → Continue
Is it a collection of items (users, posts, messages)?
├─ YES → Does it have relationships?
│ ├─ YES (user has many posts) → Relational Database
│ │ └─ Stop here
│ └─ NO (simple list) → Database or File (JSON)
│ └─ Stop here
└─ NO → Continue
Is it temporary data for this session only?
├─ YES → Keep in Memory (RAM)
│ └─ Stop here
└─ NO → Use Database or Files
Concrete Examples
User's dark mode preference: → Key-Value Storage (Small, simple)
List of 5,000 messages with user info: → Relational Database (Collection, has relationships)
User's downloaded photos: → File Storage (Large, unstructured)
Auth token: → Secure Storage (Sensitive)
Currently displayed messages: → In-Memory (Fast access, temporary)
Complex business data (orders, inventory): → Relational Database (Structured, queryable, relationships)
Storage Lifecycle and Cleanup
Where data lives and how long it stays matters.
┌─────────────────────────────────────┐
│ App Lifecycle │
├─────────────────────────────────────┤
│ Launch │
│ ↓ │
│ Load from Secure Storage → Auth │
│ Load from Database → Recent data │
│ Load from Memory Cache → Session │
│ ↓ │
│ User interacts │
│ ↓ │
│ Background/Pause │
│ ↓ │
│ When low on memory: │
│ - Memory cache cleared │
│ - Keep Database + Secure Storage │
│ ↓ │
│ Logout: │
│ - Clear all caches │
│ - Clear Secure Storage (token) │
│ - Keep Database (can delete or not)│
│ ↓ │
│ Terminate │
└─────────────────────────────────────┘
Best practice example:
class UserRepository(
private val database: UserDatabase,
private val secureStorage: SecureStorage
) {
// Memory cache (clears on app close)
private var cachedUser: User? = null
suspend fun getUser(id: Int): User {
// 1. Check memory
cachedUser?.let { return it }
// 2. Check disk
database.getUser(id)?.let {
cachedUser = it
return it
}
// 3. Check network
val fresh = api.getUser(id)
database.saveUser(fresh)
cachedUser = fresh
return fresh
}
fun logout() {
// Clear everything
cachedUser = null
database.deleteAllData()
secureStorage.clearToken()
}
fun onMemoryPressure() {
// Clear memory only, keep disk
cachedUser = null
}
}
This tiered approach gives you performance (memory is fastest) + durability (disk survives app close) + security (proper cleanup on logout).