AlgoInsight

Free lesson · Mobile System Design Interviews

Data Storage on Mobile - Choosing the Right Home for Your Data

Easy15 min readmobilemobile-system-designinterview-prep

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:

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

Not Good For

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

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

Not Good For

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

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/>&nbsp;&nbsp;kSecClass as String: kSecClassGenericPassword,<br/>&nbsp;&nbsp;kSecAttrAccount as String: "userToken",<br/>&nbsp;&nbsp;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 @Keychain property 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

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:

  1. User preferences (dark mode, notifications) → Key-Value Storage
  2. Auth token → Secure Storage
  3. List of conversations → Database (need to search, filter, sort)
  4. Message text → Database
  5. Message images → File Storage (or database if small enough)
  6. Currently displayed messages → In-Memory Cache (for fast scrolling)
  7. Downloaded media files → File Storage

This layered approach gives you security, performance, and scalability.

Interview Tips: Storage Questions

They Might Ask:

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

  1. Different data needs different homes—choose the right storage type
  2. Secure storage is mandatory for passwords, tokens, and API keys
  3. Databases excel at relationships—don't try to replace them with files
  4. Keep memory usage low by not caching unnecessary data
  5. Encryption is automatic when you use the OS's secure storage—use it!
  6. 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:

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:

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:

💡 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:

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:

Some data 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:

Cons:

Size estimate for 1,000 user objects:

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:

Cons:

Size estimate for 1,000 user objects:

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:

Cons:

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).

Back to Mobile System Design Interviews