skip to Main Content

Stop Guessing Cache Lifetimes: TTL-Based Storage on Android

March 15, 20265 minute read

  

In backend development, caching strategies like write-through, write-behind, and write-around are common. Many of us are also familiar with TTL (Time-to-Live) in caches like Redis, which automatically expire stale data.

But in Android, local storage like SharedPreferences or databases doesn’t have built-in TTL support. So how do you implement automatic expiration of cached or temporary data?

The answer: WorkManager.

Not a member? Read the full article for free here →

Why TTL Matters in Android

Imagine storing a user session token in SharedPreferences. Without TTL, if you forget to clear it after logout or after expiration, your app could behave unpredictably or even cause security risks.

In backend caches like Redis, you simply set an expiration:

SET user_token abc123 EX 600

…but in Android, we need to implement this ourselves. That’s where WorkManager comes in.

Using WorkManager to Implement TTL

WorkManager allows scheduling background work that is reliable, even if the app is killed.

We can leverage OneTimeWorkRequest to schedule a delayed deletion of cached data. We use OneTimeWorkRequest instead of PeriodicWorkRequest because:

  • PeriodicWorkRequest has a minimum interval of 15 minutes.
  • We might need finer-grained TTLs (seconds or minutes).
  • We don’t want to risk accidental deletion or delay, which is possible with periodic workers.

Step 1: Define a Generic TTL Interface

We start with an abstract class:

abstract class SaveWithTTL<T> {
protected abstract fun saveData(key: String, ttl: Duration)
abstract fun saveData(key: String, data: T, ttl: Duration)
}

This gives us a generic contract for saving data with TTL, regardless of storage type.

Step 2: Implement for SharedPreferences

class SavePreferenceWithTTL(
@ApplicationContext private val context: Context,
private val sharedPreferences: SharedPreferences
) : SaveWithTTL<String>() {
override fun saveData(key: String, ttl: Duration) {
DataTTLWorker.createWorkerRequest(context, key, ttl)
}
override fun saveData(key: String, data: String, ttl: Duration) {
sharedPreferences.edit().putString(key, data).apply()
saveData(key, ttl)
}
}
  • Saves data in SharedPreferences.
  • Schedules the TTL worker to remove it after the specified duration.

Step 3: Create the TTL Worker

@HiltWorker
class DataTTLWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val sharedPreferences: SharedPreferences
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
val key = inputData.getString(INPUT_DATA_KEY)
if (key != null) {
sharedPreferences.edit(commit = true) { putString(key, null) }
}
return Result.success()
}
companion object {
private const val INPUT_DATA_KEY = "KEY"
private const val WORKER_TAG = "Data_TTL_Worker"
fun createWorkerRequest(context: Context, key: String, initialDelay: Duration) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.NOT_REQUIRED)
.build()
// Each worker is tagged with the key it represents.
// This ensures that TTL workers for different keys are independent,
// and we can replace an existing worker for the same key if needed.
val tag = "$WORKER_TAG_$key"
val workerRequest = OneTimeWorkRequestBuilder<DataTTLWorker>()
.setInputData(Data.Builder().putString(INPUT_DATA_KEY, key).build())
.setConstraints(constraints)
.setInitialDelay(initialDelay)
.addTag(tag)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
tag,
ExistingWorkPolicy.REPLACE,
workerRequest
)
}
}
}

✅ Key points:

  • commit = true ensures immediate deletion.
  • enqueueUniqueWork with REPLACE policy resets TTL if the data is updated.
  • OneTimeWorkRequest allows any TTL, even a few seconds, which is impossible with PeriodicWorkRequest.
  • The tag uniquely identifies the worker for a particular data key.

Why it matters:

  • Multiple TTL workers may exist simultaneously for different keys. Using a tag ensures each worker is associated with the correct key.
  • When you call enqueueUniqueWork with ExistingWorkPolicy.REPLACE, WorkManager cancels the previous worker with the same tag. This resets the TTL if the key is updated before expiration.
  • Format used: “$WORKER_TAG_$key” → Combines a fixed prefix with the actual key, making it readable and traceable in WorkManager logs or debugging.

Step 4: Using the TTL Cache

val sharedPreferences = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
val cache = SavePreferenceWithTTL(context, sharedPreferences)
// Save a value with TTL of 10 minutes
cache.saveData("user_token", "abc123", Duration.ofMinutes(10))
// Save a key to expire only
cache.saveData("temp_flag", Duration.ofSeconds(30))

After the TTL expires, the data is automatically removed.

Backend-Controlled TTL via HTTP Cache Headers

One of the powerful advantages of this approach is that TTL can be controlled from the backend.

For example, if your backend sends a response with a Cache-Control header:

Cache-Control: max-age=600

You can read the max-age value and set it as the TTL when caching the response locally.

This allows you to adjust cache lifetimes dynamically without releasing a new app version. The backend effectively dictates how long cached data should live, keeping your app data fresh and consistent with server-side policies.

Why This Approach Works

  • Reliable: Works even if the app is killed or the device restarts.
  • Extensible: Can be applied to databases, files, or any local storage.
  • Safe: Using OneTimeWorkRequest avoids the pitfalls of PeriodicWorkRequest and accidental data loss.
  • Resettable TTL: Updating the same key replaces the previous worker, effectively resetting its TTL.
  • Backend-configurable TTL: Use max-age headers to control cache expiration without pushing app updates.
  • Key-based tags: Ensure TTL workers are isolated per key and can be safely replaced on updates.

Possible Improvements

  • Add callbacks or LiveData notifications on data expiration.

Conclusion

Implementing TTL in Android is straightforward with WorkManager. While Android lacks native TTL support like Redis, combining SharedPreferences or local storage with OneTimeWorkRequest gives you a robust and reliable cache expiration system.

You can now:

  • Keep cached data fresh.
  • Avoid accidental stale data.
  • Let the backend dynamically control cache lifetimes via Cache-Control headers.
  • Ensure TTL workers are isolated per key with tags for safe updates.

This approach is ideal for caching network responses, session data, feature flags, or any temporary local data — without compromising safety or reliability.


Stop Guessing Cache Lifetimes: TTL-Based Storage on Android was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.

 

Web Developer, Web Design, Web Builder, Project Manager, Business Analyst, .Net Developer

No Comments

This Post Has 0 Comments

Leave a Reply

Back To Top