A lot of people ask me why I hate SharedPreferences, and at my job some people are even arguing with me that SharedPreferences are a good thing and that they don’t lead to any problems whatsoever. But from my six years of development experience and more than 15 projects, I know that SharedPreferences are literally the number one cause of ANRs in many popular apps and third-party frameworks.
You will always have ANRs because of them, no matter what you do (no, edit doesn’t help!). And in this post I will spill expose why you should remove SharedPreferences from your project ASAP.
Problem #1: SharedPreferences are fundamentally flawed and you can’t fix that with any code you write
The problem isn’t how SharedPreferences are implemented internally - it’s in the paradigm of providing synchronous access to disk data. A priori, this will always lead to problems, because by definition of synchronous access, you cannot avoid reading the resource on the same thread that you called it. SharedPreferences create a fake facade of asynchronicity. They provide you APIs like apply and commit, but under the hood they still read from disk on main thread.
Why, you ask? Because it’s impossible to do otherwise. You have to read a file, and reading a file takes an arbitrary amount of time, sometimes more than 5 seconds. And I will talk about why later, but right now let’s take that for granted. So you try to read a file. Where are you gonna get those 5 seconds from? If you’re trying to read from SharedPreferences (or create their object) directly during application startup, or in composable code, or while the application is initializing its dependency injection graph, you will cause an ANR. If the file system is contended and you have to wait, then guess what thread will wait for that file read? Of course, the main thread, because you invoke SharedPreferences constructor/operations on main thread.
If you want to not do that, try to get rid of all references to SharedPreferences on main thread. You will face a huge issue because SharedPreferences API assumes that you can, for some reason, create and call them safely on whatever thread you run. And SharedPreferences API doesn’t indicate in any way that it will perform a synchronous file creation and read on whatever thread you try to create an object. That’s obviously a huge flaw in the design and should never have been allowed. With SharedPreferences, it’s just too easy to make this stupid mistake. There is no suspend modifier on any calls, there is no proper flow-based, coroutine-based API for observing the changes, there is no reactive way to retrieve the parameters. That’s not an AOSP bug, but a design flaw. And spoiler so you don’t try to actually do it: that won’t help.
The reason it is designed that way is because we didn’t know better in 2011. SharedPreferences were there from the first version of Android, and that was the time where we used Java. We didn’t care about main thread, we didn’t know what problems reading files on main thread can even cause. Nobody cared about ANRs because users didn’t mind and were used to all kinds of errors, and we didn’t have coroutines which allow us to write concurrent code procedurally. But now we do have all these things, and we have much higher standards for our applications. Why would we still use SharedPreferences nowadays?
If you want proofs, here’s the source code of literally the Android SDK:
@Override
@Nullable
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
private void awaitLoadedLocked() {
if (!mLoaded) {
// Raise an explicit StrictMode onReadFromDisk for this
// thread, since the real read will be in a different
// thread and otherwise ignored by StrictMode.
BlockGuard.getThreadPolicy().onReadFromDisk();
}
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
if (mThrowable != null) {
throw new IllegalStateException(mThrowable);
}
}The StrictMode violation there is even explicit! This code awaits a full “load” of the SharedPreferences on whatever thread you try to get the value from if it hasn’t managed to load the file fully yet (awaitLoadedLocked).
So, what that means is that when you happily call sharedPreferences.getBoolean("isDarkMode") in your composable code, you are essentially adding an ANR and freeze to your app under file system contention circumstance. Not so confident now?
The only reason you haven’t encountered this freeze is that you were lucky. You created SharedPreferences early enough and so far apart from your actual reads that the system managed to read from a file on time on your specific set of devices, or that your app wasn’t frozen for exactly five seconds. For example, it was frozen for two and a half seconds. But if you ever find yourself wondering where those mysterious freeze and lag complaints come from your negative Play Store reviews from users on cheap devices, maybe that’s the place?
Problem #2: The QueuedWork hack that hides ANRs from you
Let’s talk about another place where SharedPreferences cause ANRs. I get why many people don’t think that SharedPreferences cause their ANRs: it’s because they are so convoluted that I didn’t believe it either at first. The main reason the ANRs happen is that when you call apply on SharedPreferences, they give you a fake promise of asynchronous work. The reason they do is because the authors of SharedPreferences wanted to make those operations fast for main thread and then offload the work to some other place where it would be “safer” to block. Because of that they created the so-called QueuedWork class:
/**
* Trigger queued work to be processed immediately. The queued work is processed on a separate
* thread asynchronous. While doing that run and process all finishers on this thread. The
* finishers can be implemented in a way to check weather the queued work is finished.
*
* Is called from the Activity base class's onPause(), after BroadcastReceiver's onReceive,
* after Service command handling, etc. (so async work is never lost)
*/
public static void waitToFinish() {
long startTime = System.currentTimeMillis();
boolean hadMessages = false;
synchronized (sLock) {
if (DEBUG) {
hadMessages = getHandler().hasMessages(QueuedWorkHandler.MSG_RUN);
}
handlerRemoveMessages(QueuedWorkHandler.MSG_RUN);
if (DEBUG && hadMessages) {
Log.d(LOG_TAG, "waiting");
}
// We should not delay any work as this might delay the finishers
sCanDelay = false;
}
// author's note - [1]
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
try {
processPendingWork();
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
try {
while (true) {
Runnable finisher;
synchronized (sLock) {
finisher = sFinishers.poll();
}
if (finisher == null) {
break;
}
finisher.run();
}
} finally {
sCanDelay = true;
}
synchronized (sLock) {
long waitTime = System.currentTimeMillis() - startTime;
if (waitTime > 0 || hadMessages) {
mWaitTimes.add(Long.valueOf(waitTime).intValue());
mNumWaits++;
if (DEBUG || mNumWaits % 1024 == 0 || waitTime > MAX_WAIT_TIME_MILLIS) {
mWaitTimes.log(LOG_TAG, "waited: ");
}
}
}
}Take a look at [1]! This is literally a hack that hides from you the problem of writing to disk. The creators of SharedPreferences lie to you - they disable StrictMode that you enabled through hard work, temporarily, just because they don’t want you to see them using this hack.
This class specifically is created for hiding the asynchronous file reads and committing them in certain lifecycle callbacks. When SharedPreferences execute commit or apply, instead of writing to file immediately, they queue the work using this class. Then the Android framework calls specific methods of this class to commit or “finalize” the pending work (on main thread) to the disk. In the sources, the justification for this is that “the work is never lost”. But as we already established, that’s an outdated and false premise. That was created to give consumers a false sense of security that their file writes will never be lost, somewhere around 2010s or whatever. This is a flawed approach because instead of properly relying on the consumer to actually finish and await the call that is supposed to commit to a file, or using something like eager file writes with write-ahead logging, or even simply a copy-on-write algorithm, the SharedPreferences just postpone the negative consequences of delaying asynchronous work to some other method where they assumed that “it would be safe to block” the main thread because the UI doesn’t care. But that isn’t true at all since the application and activity lifecycle have changed significantly over the years and we are no longer using the activity lifecycle methods the same way that we used to back in the days.
This also derails your attempts at finding out about those ANRs, because the stack trace will now never point to the actual SharedPreferences call, which is what I warned about in my previous post on ANRs.
Here are example stack traces you’ll get concretely in the app lifecycle (AOSP ActivityThread.java):
- Activity pause (older pre-HC apps): after
performPauseActivity(...),QueuedWork.waitToFinish()is invoked. Expect stacks likeActivityThread.handlePauseActivity → QueuedWork.waitToFinish. - Activity stop (modern apps): after
performStopActivityInner(...), the framework runsQueuedWork.waitToFinish(). Expect stacks likeActivityThread.handleStopActivity → QueuedWork.waitToFinish. - Sleeping:
handleSleepingalso callsQueuedWork.waitToFinish(). - Service start args:
handleServiceArgsdrains queued work before reportingserviceDoneExecuting(...). Expect stacks likeActivityThread.handleServiceArgs → QueuedWork.waitToFinish. - Service stop:
handleStopServicealso drains. Expect stacks likeActivityThread.handleStopService → QueuedWork.waitToFinish.
Bonus: commit() can still write on the caller (UI) thread
For commit() (sync), enqueueDiskWrite(..., /*postWriteRunnable=*/ null) treats it as a synchronous commit and may run writeToDiskRunnable.run() on the current thread (see the isFromSyncCommit and wasEmpty fast-path). If you ever call commit() from the main thread, that’s immediate UI-thread I/O.
”But it’s only 2 milliseconds!”
But here’s the last point that people who argue with me bring up usually:
“Oh, but it’s only a 2 millisecond read. It’s nothing. It’s just an atomic file operation on main thread… I saw StrictMode violation and it says the duration was 5 milliseconds. Nobody is ever going to notice that! We are not gonna fix it because it is just a small delay. You’re just making a mountain out of a molehill!”
As we already established, the first point is that the apply isn’t really asynchronous. It just queues the work to be done, but then registers a finisher which will be run in various lifecycle callbacks. So if that call ever happens to be slow, then you will get an ANR in one of those callbacks or at application start. That should be obvious that it can manifest in negative consequences now.
The second point is that the actual on-disk flush is a blocking fsync/sync - and Android’s own source tracks its tail latency. The write path ends in FileUtils.sync(out) → out.getFD().sync(), i.e., a blocking flush. SharedPreferencesImpl even logs/records fsync durations and warns when they exceed a threshold, which exists because fsync can be slow.
In simple terms, this means that cheap devices, slow memory, external memory such as SD cards, and at various points of system lifecycle where there are high amounts of workload (e.g. the user is performing some heavy file access operations such as transcoding media or playing a high quality video), there is a very high likelihood that the system calls that issue disk writes will enter contention for resources. This doesn’t reproduce in sterile QA testing or debug builds environments, especially if the team uses an emulator or some sort of expensive device that has enough bandwidth to handle all the file writes. And especially since the data that is usually being stored in SharedPreferences on debug builds is much smaller in size than it can get on production builds. (You’re not writing unbounded lists to a single XML file, are you?)
What that means is that this is an expected behavior from the system. When the OS syscall enters contention and suspends the thread to perform a write/read and synchronize op to the file system, it may wait for an indefinite amount of time for other operations to finish or for the system security service to grant the app access to its data directories or external storage, which can lead to, in some rare cases, the operating system file operations taking significantly longer than they are used to. This is very hard to reproduce because you aren’t usually running stress tests or load testing of your app on a 100$ Xiaomi budget device, are you? But this is how real-world devices operate. For example, if you try to perform file r/w in a BOOT_COMPLETED broadcast, like I mentioned in my previous post, you have the “best” chance of catching this contended state because hundreds of other apps are also running a boot completed broadcast at the same time, and as you know, broadcasts are executed on main thread. So if you ever wondered why your broadcast stack traces show up in ANR reports in Crashlytics, then this is your answer.
Even though direct file system contention isn’t as much of an issue as it used to be, this improvement is greatly compensated for by the introduction of scoped storage and new SAF and FUSE policies on the Android side, which are much slower than standard reads. So when the user’s device writes gigabytes of data, and your app happens to be open, guess who’s gonna bear the burden of a huge journal flush caused by a fsync call? For skeptics, here’s a paper that measured real delays in ext* file systems.
And guys, not to take credit for this, but if you think this is some new information that nobody previously knew about, I’m honestly just expanding a bit on an official Android developers documentation, so even for those that say “Oh, we will only do what Google recommends and not some random guy on the internet”, please be my guest and implement a better I/O architecture.
The invisible problem: freezes that don’t show up in Crashlytics
My problem here when discussing this is that I’m gonna be honest with you guys, I can’t give you charts showing fsync latency is going off the rails. There is no public data - believe me, I searched - for this kind of metrics because they are internal Android analytics, it isn’t at all in Google’s interests to disclose how bad the file read operations are. I can point you to a hundred different stack traces from my own apps and towards discussions of thousands of other people complaining about this exact same issue, but I cannot give you a definitive conclusion on how many users exactly are facing this issue.
If you target cheaper devices which are still very popular in the world, you are bound to have ANRs due to this. Or what’s worse in my opinion is that you will likely not have real ANRs which are reported to Crashlytics. What you’re likely suffering from is significant delays and freezes in the app that the users are aware and perceive, but those are not long enough to trigger an actual ANR. This ruins your app’s user experience but doesn’t surface in any of Crashlytics services or bug reports because it’s not as evident. All you will see is some strange user complaining about how the app is slow or laggy or constantly freezes and you will dismiss their review as an outlier. But then you will wonder why your users are leaving so much.
So don’t take this lightly. If you believe in official documentation and real world issues and this explanation, try using some other approach other than SharedPreferences. It isn’t that hard, I promise. For example, DataStore offers you a safe suspending API with the copy-on-write algorithm under the hood. I know that some people don’t like, saying “it isn’t as convenient to use” because it doesn’t give you a synchronous API to invoke on main thread. But you know, maybe the design of DataStore is like that for a reason? And we should think about why we are facing challenges during development, and sometimes go along with them, instead of opting for the easiest solution possible.
A proper asynchronous API for disk operations and strong developer ethics will not just solve your ANR problem, they will teach you how to work with data reactively and efficiently.