Broadcasts
A BroadcastReceiver gets one callback and no more. That constraint is the point — and the reason Android spent a decade restricting who may receive what.
Every system event you can think of — boot completed, airplane mode toggled, a package installed, the battery getting low — is delivered as a broadcast. Understanding why so few of them can still reach your manifest is understanding a decade of Android's background policy.
TL;DR#
- A receives an and runs
onReceiveonce. There is no second callback. - Manifest-registered receivers can wake your app; context-registered receivers live only as long as the registering component.
- Since Android 8.0, most implicit broadcasts cannot be declared in the manifest.
onReceiveruns on the main thread, and has roughly 10 s (foreground) or 60 s (background) before an ANR.1- Ordered broadcasts can be aborted or modified; normal broadcasts cannot.
Mental model#
A broadcast is a public address announcement in a building.
Anyone who has signed up at the front desk hears it, whether or not they are at their desk. That is a manifest receiver: the building knows where to find you.
Anyone who is currently standing in the lobby with a radio hears it too, but only while they are there. That is a context-registered receiver: registered in code, gone when the component goes.
Where the analogy breaks#
Announcements in a building reach everyone in it. An Android broadcast is filtered before delivery and, since Android 8, most system announcements are deliberately not delivered to apps that are not already running. The mental correction: a broadcast is not "everyone hears it" but "the system decides who is eligible, then delivers to the eligible receivers".
The big picture#
Text description of this diagram
A sequence diagram of the component start path. The caller crosses Binder into system_server, which resolves the target against installed components, checks export and permission, pauses the current activity where relevant, and asks Zygote for a process if the target app is not running. The new process binds its Application, then ActivityThread dispatches the component callback. For a broadcast the server-side path differs — ActivityManagerService delivers to registered receivers and can spawn processes for manifest receivers — but process creation, UID drop and main-thread dispatch follow the same route.
The diagram shows why a broadcast can be expensive: if the receiver is manifest-declared and the app
is not running, delivering one Intent may mean forking a process, loading the app, running
Application.onCreate, and only then calling onReceive.
Deep dive#
Two kinds of registration#
class BatteryWatcher : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_POWER_CONNECTED -> context.startService(
Intent(context, ChargingService::class.java)
)
}
}
}Manifest registration:
<receiver
android:name=".BootReceiver"
android:exported="true"
android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>Context registration:
class ChatActivity : ComponentActivity() {
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
viewModel.onMessage(intent.getStringExtra(EXTRA_ID))
}
}
override fun onStart() {
super.onStart()
ContextCompat.registerReceiver(
this,
receiver,
IntentFilter(ACTION_NEW_MESSAGE),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
}
override fun onStop() {
unregisterReceiver(receiver)
super.onStop()
}
}The flags on registerReceiver matter more than they used to. Since Android 13, targeting apps must
state whether a receiver is exported, and the compat helper makes the choice explicit.
The manifest restriction#
Android 8.0 stopped delivering most implicit broadcasts to manifest-declared receivers. The reason is scalability: with a hundred apps installed, a single system event could wake dozens of processes.
The rule is a list of exempt broadcasts — ACTION_BOOT_COMPLETED, ACTION_LOCALE_CHANGED,
ACTION_MY_PACKAGE_REPLACED and a handful more — that still work from the manifest.2 Everything else
must be registered at runtime, or the app must use a different mechanism entirely.3
The alternative mechanisms are worth naming because they are the modern answer:
WorkManagerfor deferrable work that must happen eventually.- A foreground service for work that must happen now and visibly.
JobSchedulerfor work triggered by conditions rather than by an event.- Direct callbacks (a bound service, a
Flow) when your own process is the only listener.
Ordered, normal, and sticky#
| Kind | Delivery | Can it be aborted or modified? |
|---|---|---|
| Normal | to all matching receivers, unspecified order | no |
| Ordered | one at a time, in priority order | yes, with abortBroadcast() and result extras |
| Sticky (deprecated) | the last broadcast is retained and replayed to new receivers | n/a |
Ordered broadcasts are used by the system where sequence matters — for example, SMS delivery notification. They are also a performance hazard: every receiver in the chain runs before the next one starts, and the whole chain is subject to the same timeout budget.
sendOrderedBroadcast accepts a final BroadcastReceiver that runs after all others, which is a
useful way to observe the result without registering:
sendOrderedBroadcast(
Intent(ACTION_SYNC_REQUEST),
null,
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val count = getResultExtras(true).getInt(EXTRA_COUNT)
log("sync handled by $count receivers")
}
},
null, Activity.RESULT_OK, null, null,
)Security: exported, permission, and implicit targets#
A broadcast receiver is an attack surface. Three defences, in order of strength:
- Do not export it.
android:exported="false"means only your app (or a component with your UID) can send to it. - Require a permission.
android:permissionon the receiver means the sender must hold it. A signature-level custom permission restricts it to apps signed with your key. - Validate the payload. An exported receiver will receive intents from anyone; treat extras as untrusted input.
<permission
android:name="com.example.notes.permission.INTERNAL_BROADCAST"
android:protectionLevel="signature" />
<receiver
android:name=".InternalReceiver"
android:exported="true"
android:permission="com.example.notes.permission.INTERNAL_BROADCAST" />There is a symmetric hazard on the sending side. An implicit broadcast you send can be received by any app that declares a matching filter, so never put a secret in the extras of an implicit broadcast. Send an explicit broadcast to your own package, or require a permission on the send.
Why a broadcast can arrive twice#
There are three distinct duplication sources, and they are worth separating because the fixes differ.
Two matching filters on one receiver. A receiver registered for both ACTION_POWER_CONNECTED and a
custom action can receive an intent that matches both if the sender sets both. Filter per action, and
check intent.action in onReceive.
Sticky broadcasts. A deprecated sticky broadcast replays the last value to every new receiver. A receiver registered twice in a session gets the replay twice.
Redelivery after a crash. An ordered broadcast whose receiver process dies mid-delivery can be redelivered when the process restarts. The system cannot know whether the receiver completed, so the contract is at-least-once. Make the handler idempotent.
The broadcast queue is shared#
Broadcast delivery is not one queue but several, and ordered broadcasts run one receiver at a time. A slow receiver therefore delays every receiver behind it — including system ones.
That is the real reason for the timeout, and it explains a symptom that looks unrelated: a broadcast sent by your app arrives seconds late on a device where another app's receiver is slow. The queue is a shared resource, and the platform's enforcement is a form of back-pressure.
// Ordered broadcast with a final receiver: the result arrives after every other receiver.
sendOrderedBroadcast(
Intent(ACTION_PING),
null,
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val handled = getResultCode() == Activity.RESULT_OK
log("handled=$handled")
}
},
null, Activity.RESULT_CANCELED, null, null,
)getResultCode() and getResultExtras() are only meaningful in an ordered broadcast, because they are
the mechanism by which one receiver passes a result to the next.
The ten-second rule#
onReceive runs on the main thread of whatever process hosts the receiver. The system expects it to
return quickly: about ten seconds for a foreground broadcast, sixty for a background one. Exceeding
that produces an ANR — and because the broadcast queue is shared, a slow receiver delays every other
receiver behind it.1
// Wrong: the receiver blocks the broadcast queue.
override fun onReceive(context: Context, intent: Intent) {
val data = api.fetchBlocking() // network on the main thread
database.write(data)
}
// Right: enqueue durable work and return.
override fun onReceive(context: Context, intent: Intent) {
val id = intent.getStringExtra(EXTRA_ID) ?: return
WorkManager.getInstance(context)
.enqueue(SyncWorker.request(id))
}goAsync() exists for the narrow case where you need a few more seconds of background work without
starting a service:2
override fun onReceive(context: Context, intent: Intent) {
val pending = goAsync()
scope.launch {
try {
process(intent)
} finally {
pending.finish()
}
}
}It buys time, not immunity. The process can still be killed after the timeout, and the work is not
retried. WorkManager remains the correct tool for anything that matters.
Testing a receiver#
A receiver is a function from (Context, Intent) to side effects, which makes it testable without a
device if the side effects are injected:
@Test
fun bootReceiver_enqueuesSync() {
val receiver = BootReceiver()
val intent = Intent(Intent.ACTION_BOOT_COMPLETED)
receiver.onReceive(context, intent)
verify(workManager).enqueue(any())
}The Context is the hard part; it is why the receiver should delegate immediately to an injected
object rather than doing work itself. That delegation is the same pattern as the ten-second rule, seen
from a testing angle: a receiver that only hands off is a receiver that can be tested.
Local broadcasts are gone, and that is fine#
The old LocalBroadcastManager existed to deliver intents inside one process without crossing Binder.
It is deprecated. The replacements are ordinary callbacks: a SharedFlow in a repository, a
StateFlow in a ViewModel, or a lifecycle-aware observer. They are faster, typed, and cannot leak to
another app by accident.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 17 | 4.2 Jelly Bean |
|
| 19 | 4.4 KitKat |
|
| 21 | 5.0 Lollipop |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 34 | 14 Upside Down Cake |
|
| 35 | 15 Vanilla Ice Cream |
|
| 36 | 16 Baklava |
|
The trajectory is one-directional: every release made it harder for a broadcast to start an app that the user was not using. The component survived by becoming a delivery mechanism for events the app already cares about, not a way to wake up.
Under the hood#
- BroadcastReceiver.javathe receiver base class, goAsync and the result API
- BroadcastQueue.javathe delivery queue and its timeout enforcement
- BroadcastController.javaregistration and the manifest-broadcast policy
- IntentFilter.javathe matching rules shared with activity resolution
Curiosity corner#
- The boot broadcast is one of the last reliable wake-ups.
ACTION_BOOT_COMPLETEDstill starts apps from the manifest, which is why it is heavily abused by malware and heavily monitored by Play. - A broadcast can be received twice if you register the same receiver with two filters that both match. Deduplicate by intent identity if it matters.
onReceivecan be called on a Binder thread for some system broadcasts. Never assume the main thread when you are doing work that touches UI.
Try it#
adb shell am broadcast -a android.intent.action.AIRPLANE_MODE --ez state true
adb logcat -s BroadcastQueue:I ActivityManager:I
Check yourself#
Check yourself
You call `startActivity()` for an activity in another app. Which check does the system perform before delivering the intent?
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsAnrdeveloper.android.comaccessed 2026-10-27↩
- docsBroadcasts overview | Background workdeveloper.android.comaccessed 2026-10-27↩
- docsPerubahan Perilaku Android 8.0developer.android.comaccessed 2026-10-27↩
- docsFeatures and APIs overview | Android 12developer.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩
- docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩