Zygote
Components & Intentsintermediate

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.

11 minAPI 1–37 · 17interactivequizverified 2026-10-27

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 onReceive once. 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.
  • onReceive runs 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#

Loading diagram…
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:

  • WorkManager for deferrable work that must happen eventually.
  • A foreground service for work that must happen now and visibly.
  • JobScheduler for 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#

KindDeliveryCan it be aborted or modified?
Normalto all matching receivers, unspecified orderno
Orderedone at a time, in priority orderyes, with abortBroadcast() and result extras
Sticky (deprecated)the last broadcast is retained and replayed to new receiversn/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:

  1. Do not export it. android:exported="false" means only your app (or a component with your UID) can send to it.
  2. Require a permission. android:permission on the receiver means the sender must hold it. A signature-level custom permission restricts it to apps signed with your key.
  3. 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#

Background and power changes12 releases
APIVersionWhat changed
11.0
  • The original android.* SDK: Activity, Service, BroadcastReceiver, ContentProvider
174.2 Jelly Bean
  • Daydream service
194.4 KitKat
  • AlarmManager becomes inexact by default for targeting apps
215.0 Lollipop
  • JobScheduler
  • Implicit service intents restricted for targeting apps
268.0 Oreo
  • Background service limits
289 Pie
  • Foreground service permission
2910
  • Background activity starts restricted
3011
  • Background location requires a second request
3112
  • Exact alarms need SCHEDULE_EXACT_ALARM
  • Foreground service launch restrictions from the background
3414 Upside Down Cake
  • Foreground service types required when targeting 34
  • User-initiated data transfer jobs
3515 Vanilla Ice Cream
  • Deprecated APIs: WindowInsetsController hiding, elevate the importance of foreground services
3616 Baklava
  • JobScheduler quota changes

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#

Curiosity corner#

  • The boot broadcast is one of the last reliable wake-ups. ACTION_BOOT_COMPLETED still 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.
  • onReceive can 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#

Send a broadcast from the shell

adb shell am broadcast -a android.intent.action.AIRPLANE_MODE --ez state true

Watch broadcast delivery

adb logcat -s BroadcastQueue:I ActivityManager:I

Check yourself#

Check yourself

1 / 12

You call `startActivity()` for an activity in another app. Which check does the system perform before delivering the intent?

0 answered · 0 correct

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.

  1. docsAnrdeveloper.android.comaccessed 2026-10-27↩
  2. docsBroadcasts overview | Background workdeveloper.android.comaccessed 2026-10-27↩
  3. docsPerubahan Perilaku Android 8.0developer.android.comaccessed 2026-10-27↩
  4. docsFeatures and APIs overview | Android 12developer.android.comaccessed 2026-10-27↩
  5. docsBehavior changes: Apps targeting Android 16 or higherdeveloper.android.comaccessed 2026-10-27↩
  6. docsBehavior changes: Apps targeting Android 17developer.android.comaccessed 2026-10-27↩