All results from shipping Mac×Android tools as a solo developer. Tested across multiple Android versions including Android 16.
I lost 4 hours to a silent Wi-Fi ADB disconnect. The device showed as connected. Commands returned nothing. No error, no crash — just silence. Here's the keepalive pattern that fixed it, and everything else I learned building HiyokoAutoSync on top of Wi-Fi ADB.
Speed comparison
| Operation | USB | Wi-Fi (same network) |
|---|---|---|
adb push 100MB file |
~3–5s | ~8–15s |
adb shell command |
<50ms | 80–200ms |
| Screenshot capture | ~200ms | ~400–700ms |
| Log streaming | Stable | Occasional drop |
Wi-Fi ADB runs over TCP/IP on port 5555. You're limited by your local network throughput, not USB 2.0/3.0. On a congested network, latency spikes unpredictably.
For file sync use cases — pushing a few hundred MB — the gap is real but livable. For anything real-time (log streaming, screen mirroring), USB wins clearly.
The silent disconnect problem
USB is deterministic. The connection either works or it doesn't, and when it breaks, you know immediately.
Wi-Fi ADB drops silently. adb devices still shows the device as connected for several seconds after it's actually gone. If you're building a tool on top of ADB, you need explicit keepalive logic — or you'll spend hours debugging commands that appear to run but do nothing.
// Keepalive pattern for Wi-Fi ADB in Rust
async fn check_connection(device_id: &str) -> bool {
let output = Command::new("adb")
.args(["-s", device_id, "shell", "echo", "ok"])
.output()
.await;
match output {
Ok(out) => out.status.success(),
Err(_) => false,
}
}
Poll this every 5 seconds in a background task. When it returns false, mark the device as disconnected immediately — don't wait for the next command to fail.
The Android 16 wrinkle
Android 16 tightened wireless debugging permissions. On some devices, Wi-Fi ADB now requires re-pairing after reboot even if you previously paired via QR code. USB connections are unaffected.
If your users are on Android 16 and hitting "device not found" after a restart, this is likely why. HiyokoShot broke on Android 16 for exactly this reason — the pairing state wasn't persisting across reboots on certain OEMs.
When to use USB
- File transfer (anything over 50MB)
- Real-time log streaming
- Screen mirroring / scrcpy
- First-time setup and pairing
- Debugging connection issues
When to use Wi-Fi
- Quick shell commands while the phone is across the room
- Automated background sync (if you handle reconnection logic)
- Situations where plugging in isn't practical
- Testing on a device mounted in a fixed location
Setting up Wi-Fi ADB (Android 11+)
# On the device: Developer Options → Wireless debugging → Pair device with pairing code
# Then on Mac:
adb pair 192.168.1.x:PORT
# Enter the pairing code shown on the device
adb connect 192.168.1.x:5555
adb devices
# Should show: 192.168.1.x:5555 device
Android 10 and below requires USB first:
adb tcpip 5555
# Unplug USB
adb connect 192.168.1.x:5555
The hybrid approach: USB → Wi-Fi auto-switch
The most robust setup is USB for initial connection, then automatic switch to Wi-Fi. Here's the actual implementation pattern:
// 1. Detect connection type from `adb devices` output
fn is_wifi_device(device_id: &str) -> bool {
// Wi-Fi devices appear as IP:PORT, USB devices as serial numbers
device_id.contains(':')
}
// 2. Switch a USB-connected device to TCP mode
async fn switch_to_wifi(serial: &str) -> Result<String, String> {
// Enable TCP on the device
Command::new("adb")
.args(["-s", serial, "tcpip", "5555"])
.output()
.await
.map_err(|e| e.to_string())?;
// Get device IP via shell
let output = Command::new("adb")
.args(["-s", serial, "shell", "ip", "route"])
.output()
.await
.map_err(|e| e.to_string())?;
let ip = parse_device_ip(&String::from_utf8_lossy(&output.stdout))?;
// Connect over Wi-Fi
Command::new("adb")
.args(["connect", &format!("{}:5555", ip)])
.output()
.await
.map_err(|e| e.to_string())?;
Ok(format!("{}:5555", ip))
}
// 3. Monitor with keepalive loop
async fn monitor_connection(device_id: String, tx: Sender<DeviceEvent>) {
loop {
tokio::time::sleep(Duration::from_secs(5)).await;
if !check_connection(&device_id).await {
tx.send(DeviceEvent::Disconnected(device_id.clone())).ok();
break;
}
}
}
Connect once via USB, call switch_to_wifi(), then hand the new IP-based device ID to the monitor loop. The cable can be unplugged immediately after the switch completes.
The verdict
USB is the safer default. Wi-Fi unlocks real convenience but requires you to handle reconnection explicitly — something most ADB tutorials skip entirely.
If you're building on top of ADB, design for Wi-Fi disconnects from day one. Retrofitting that logic is painful.
What's your default — cable or wireless? Curious how others handle the reconnection problem.
I built HiyokoAutoSync around exactly this pattern — USB setup, automatic Wi-Fi switch, background keepalive. If you're doing Mac×Android sync without a cable, it might save you some time.
HiyokoAutoSync → https://hiyokomtp.lemonsqueezy.com/checkout/buy/20c922f1-ca45-4f77-aeb2-04e34aad2fb4
X → @hiyoyok
Top comments (0)