Almost all applications we use or develop on Android need to store data persistently: user settings, sessions, files, structured information, or interface states. Although the concept of persistence has not changed over the years, the tools, APIs, and best practices have changed radically.
In simple terms, persisting data means that the information survives application closure, configuration changes, and even device reboots. In modern Android, choosing the wrong storage mechanism can cause data loss, performance issues, or even app rejection on Google Play.
This article analyzes all current persistent data storage techniques in Android Studio, starting from classic concepts and bringing them to the current state of Android (Jetpack, Kotlin, and Compose).
In modern versions of Android, incorrectly choosing the storage mechanism can lead to:
- Performance issues (main thread blocks)
- Security and privacy risks
- Data loss during configuration changes
- Application rejection on Google Play
In simple terms, persisting data means that the information survives the closure of the app, configuration changes, and, in many cases, device reboots. Current Android no longer relies on a single solution, but rather on a combined persistence strategy.
In this expanded and updated guide, we will look at all current persistent data storage options in Android Studio, when to use each one, practical examples, and which approaches have become obsolete.
What is persistence in Android?
In computer science, persistence consists of making data manipulated by an application survive over time, regardless of the process that created it. Colloquially speaking: that the data is not deleted when the app closes.
In modern Android, persistence is divided into three major levels, each with a different purpose:
- UI State (memory): temporary data needed so that the interface does not reset during rotations or configuration changes.
- Local persistence (disk): data that must survive the closure of the app.
- Structured persistence: relational or queryable data.
A well-designed application combines several mechanisms, rather than relying on just one.
Android explicitly recommends not mixing responsibilities. A common mistake in old articles is using databases or files to save UI state, which today is considered a bad practice.
1. Shared Preferences (SharedPreferences)
SharedPreferences allow storing simple key–value pairs like booleans, numbers, or text. Historically, they were the most widely used mechanism for saving user settings.
Features
- Storage in a private XML file
- Does not require permissions
- Ideal for small configurations
For many years, SharedPreferences was the standard solution for saving simple key–value pairs.
What it allows to store:
- Boolean
- Int, Long, Float
- String
- Set
Classic example:
Problems detected over time:
- Synchronous writes if
commit()is used - Risk of corruption
- No concurrency control
- No strong typing
Current limitations
- Potential synchronous access
- Risk of inconsistencies
- No type safety
For these reasons, it is no longer recommended as the primary solution in new apps.
1.2 DataStore (modern and recommended replacement)
DataStore is part of Jetpack and is the official replacement for SharedPreferences.
There are two variants:
DataStore Preferences
Ideal for simple configurations.
val Context.dataStore by preferencesDataStore(name = "settings")
suspend fun saveTheme(context: Context, dark: Boolean) {
context.dataStore.edit { prefs ->
prefs[booleanPreferencesKey("dark_mode")] = dark
}
}Reactive reading:
val darkModeFlow: Flow<Boolean> = context.dataStore.data
.map { prefs -> prefs[booleanPreferencesKey("dark_mode")] ?: false }Proto DataStore
Used when you need a strong schema and validation.
- Ideal for large apps
- Uses Protobuf
- Avoids errors due to incorrect keys
When to use DataStore:
- User preferences
- Configuration flags
- Small but persistent data
Advantages
- Asynchronous
- Safe from corruption
- Integration with coroutines
- Reactive observation with Flow
When to use DataStore
- User preferences
- Configuration flags
- Simple persistent states
2. File storage
2.1 Internal storage (private and secure)
Internal storage remains one of the safest and simplest options.
val file = File(filesDir, "config.txt")
file.writeText("Hello World")Features:
- Only accessible by the app
- Deleted upon uninstallation
- Ideal for sensitive data
Features:
- Does not require permissions
- Only accessible by the app
- Deleted upon uninstallation
Example in Kotlin:
val file = File(context.filesDir, "config.txt")
file.writeText("Initial configuration")Reading:
val content = file.readText()Typical use cases:
- Configuration files
- Encrypted tokens
- Private app data
2.2 External storage and Scoped Storage
Since Android 10 (API 29), Google introduced Scoped Storage.
What changed:
- Free access to the SD card removed
- WRITE/READ_EXTERNAL_STORAGE permissions are obsolete
- Limited access through sandbox
Recommended example:
val file = File(
context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS),
"report.pdf"
)To access files created by other apps, you must use:
- Storage Access Framework (SAF)
- Intent ACTION_OPEN_DOCUMENT
Important: incorrect use of storage is one of the most common causes of rejection on Google Play.
3. Databases: SQLite vs Room
3.1 Direct SQLite (low level)
SQLite remains the base engine, but handling it manually implies:
- Boilerplate code
- Runtime errors
- Difficult maintenance
Today it is considered a low-level option.
Features
- Database in a single file
- Not client-server
- Fast and lightweight
3.2 Room (current standard)
Room is Android Jetpack's official abstraction layer over SQLite.
Components:
- Entity
- DAO
- Database
Complete example:
@Entity(tableName = "users")
data class User(
@PrimaryKey val id: Int,
val name: String,
val email: String
)
@Dao
interface UserDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(user: User)
@Query("SELECT * FROM users")
fun getUsers(): Flow<List<User>>
}
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}Real advantages:
- Compile-time verification
- Integration with Flow and LiveData
- Easy testing
4. Cache: temporary data
The cache allows storing reconstructible data.
Example:
val cacheFile = File(context.cacheDir, "response.json")Best practices:
- Never critical data
- Limit size
- The system can delete it
Best practices:
- Never save critical data
- Limit size
- Clean periodically
5. UI State: ViewModel and SavedState
5.1 ViewModel
Designed to:
- Survive rotations
- Avoid unnecessary reloads
class MainViewModel : ViewModel() {
val counter = MutableLiveData(0)
}5.2 SavedStateHandle
Allows restoring state after process death.
class SearchViewModel(
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
var query: String
get() = savedStateHandle["query"] ?: ""
set(value) {
savedStateHandle["query"] = value
}
}Key rule:
UI state should not be saved in the database.
ViewModel
- Keeps data in memory
- Survives rotations
- Does not survive process kill
SavedState / rememberSaveable
- Lightweight backup
- Only small data
6. Advanced persistence: Ink API and serialization
In drawing apps:
- Serialize StrokeInputBatch
- Save Brush separately
- Export to image
This approach is essential for:
- Note-taking apps
- Whiteboards
- Real-time collaboration
Which mechanism should I use?
| Need | Recommended solution |
|---|---|
| Preferences | DataStore |
| UI State | ViewModel + SavedState |
| Relational data | Room |
| Private files | Internal storage |
| Shared files | SAF / Scoped Storage |
| Temporary data | Cache |
Conclusion
Persistence in modern Android is not based on a single technique, but on a well-thought-out architecture:
- ViewModel for UI
- DataStore for preferences
- Room for complex data
- Scoped Storage for files
Updating these concepts is essential to create applications that are secure, efficient, and aligned with current Android Studio standards.
7. End-to-end example: Complete app with ViewModel + DataStore + Room + UI
Scenario
Simple to-do (To‑Do) application that:
- Saves preferences (dark mode)
- Persists tasks in a database
- Maintains UI state during rotations
Architecture
- UI: Jetpack Compose
- State: ViewModel + StateFlow
- Preferences: DataStore
- Data: Room
DataStore – Preferences
val Context.settingsDataStore by preferencesDataStore("settings")
class SettingsRepository(private val context: Context) {
val darkMode: Flow<Boolean> = context.settingsDataStore.data
.map { it[booleanPreferencesKey("dark_mode")] ?: false }
suspend fun setDarkMode(enabled: Boolean) {
context.settingsDataStore.edit {
it[booleanPreferencesKey("dark_mode")] = enabled
}
}
}Room – Database
Room – Database
@Entity
data class Task(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val title: String,
val completed: Boolean = false
)
@Dao
interface TaskDao {
@Query("SELECT * FROM Task")
fun getTasks(): Flow<List<Task>>
@Insert
suspend fun insert(task: Task)
}ViewModel – State logic
class TaskViewModel(
private val dao: TaskDao,
private val settings: SettingsRepository
) : ViewModel() {
val tasks = dao.getTasks().stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
emptyList()
)
val darkMode = settings.darkMode.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false
)
}UI – Jetpack Compose
@Composable
fun TaskScreen(viewModel: TaskViewModel) {
val tasks by viewModel.tasks.collectAsState()
val darkMode by viewModel.darkMode.collectAsState()
LazyColumn {
items(tasks) { task ->
Text(task.title)
}
}
}8. 100% Jetpack Compose Version
rememberSaveable
It is used for:
- Input text
- Scroll
- Temporary selections
StateFlow + Compose
Advantages:
- Reactive
- Lifecycle‑aware
- Easy testing
Room + Flow + Compose
- Room exposes Flow
- ViewModel transforms it
- Compose observes it
This avoids:
- Callbacks
- Inconsistent states
9. Advanced comparison table
| Mechanism | Performance | Survives process kill | Real cases | Common errors |
| ViewModel | Very high | ❌ | UI State | Saving persistent data |
| SavedState | Medium | ✔️ | Inputs, scro |