Handler.post() Doesn't Switch Threads — It Just Drops a Message in the Right Queue
Conflating `post()` with thread-switching leads to race conditions, deadlocks, and ANRs when developers assume the calling thread's context carries over. Understanding that Handler is a mailbox, not a dispatcher, makes Android's single-threaded UI model predictable and prevents the common mistake of blocking the main thread's Looper.
The common shorthand "Handler switches threads" obscures what actually happens. A `post()` call wraps the Runnable into a Message's callback field, stamps the Handler as `msg.target`, and inserts it into the target Looper's MessageQueue. The calling thread's job ends there. The thread bound to that Looper — often the main thread — is already spinning in `Looper.loop()`, where it pulls the Message from the queue and calls `dispatchMessage()`. That dispatch path checks `msg.callback` first, which is why a posted Runnable runs instead of `handleMessage()`.
This mechanism explains why the main thread never exits after `ActivityThread.main()`: it enters `Looper.loop()` and stays there, processing lifecycle events, input, and drawing as messages arrive. A Handler is just a delivery address, not a thread, and it holds a Looper reference, not a direct Thread reference. The Looper was bound to its thread at creation via `ThreadLocal`, but other threads can still obtain that Looper reference and post work to it.
Misunderstandings cascade from treating Handler as an executor. The execution thread is always the Looper's thread, never the thread that called `post()` and never the thread where the Handler object was constructed. Explicitly passing `Looper.getMainLooper()` or a `HandlerThread`'s Looper makes the target unambiguous and avoids the crashes or misrouting that implicit construction can cause.
The phrase "Handler switches threads" is a pedagogical shortcut that creates more bugs than it prevents, because it hides the queue and the Looper's pull-based execution model.
Many developers never realize that `post()` and `sendMessage()` take different paths through `dispatchMessage()`, which is why a Runnable can silently bypass an overridden `handleMessage()`.
The `msg.target` field is the linchpin of the whole system: it decouples message insertion from dispatch, letting a Message sit in a queue as pure data until the Looper thread retrieves it and routes it back to the originating Handler.
Android's main thread is not special because of Handlers; it is special because `ActivityThread.main()` calls `Looper.prepareMainLooper()` and then enters an infinite loop, making it a message-driven runtime rather than a procedural script.