Flutter Login Worked on Emulator, Failed on Device: Android Network
We walked into this one during our Sikar internship build: auth calls hitting our Node and PostgreSQL API worked flawlessly on the Android emulator but timed out silently on a Pixel 6. Here is exactly how we traced it to Android 9+ clear text traffic blocking and fixed it.
Author
The Incident: Login Worked on Emulator, Failed on Device
The Internship Setup
Last month our 12-person SaaS team in Pune shipped a Flutter client backed by a Node.js and Express API connected to PostgreSQL. The auth flow was simple: the Flutter client used the http package to POST credentials to /auth/login, the Express server validated against PostgreSQL, and returned a JWT. During development we ran everything on a single machine: the API on localhost:3000 and the Flutter app on the Android emulator.
This is the standard setup we teach our interns at Agentic Academy Labs in Sikar. It works. Until it does not.
What We Tried and What Failed
The Emulator-Only Assumption
We hardcoded http://10.0.2.2:3000 in our Flutter config. That is the magic IP the Android emulator uses to route traffic to the host machine's localhost. It is convenient. It is also a trap.
We assumed the same address would work on a physical device. It did not. We also had no network error handling or logging in place, so when the login request timed out silently on the Pixel 6, we had nothing to go on.
The First Device Test
We built a release APK and installed it on a Pixel 6. Pressed Login. Nothing. No spinner, no error dialog, no crash. Just a silent timeout.
We checked logcat. No useful output. No SocketException, no TimeoutException. The request simply vanished.
The Root Cause: Android Network Security Config
Android 9+ Clear Text Traffic Block
Physical Android devices running Android 9 (API 28) and above block clear text (HTTP) traffic by default. This is a security feature called Network Security Configuration.
The emulator uses the special 10.0.2.2 mapping, which bypasses some of these restrictions. Physical devices do not get this luxury. They see a plain HTTP request to a non-localhost address and drop it.
The Localhost Trap
10.0.2.2 is emulator-only. It is a loopback alias that maps to the host machine. A physical device needs the actual LAN IP address of the development machine, like 192.168.1.100.
We had no port forwarding or network bridging configured. The Pixel 6 was on the same WiFi network, but it was trying to reach 10.0.2.2, which does not exist on a physical device.
This is the same issue reported in the Firebase FlutterFire community. As one developer noted on GitHub, 10.0.2.2 is the localhost address for the emulator but not for the actual device Issue #11394.
The Working Approach
Step 1: Get the Correct Device IP
On the development machine, we ran:
# Windows
ipconfig
# macOS / Linux
ifconfig | grep "inet "
We found our LAN IP: 192.168.1.100. That is the address the Pixel 6 can actually reach.
Step 2: Configure Node.js to Listen on All Interfaces
By default, Express listens on localhost only. We changed it to listen on all interfaces:
// server.js
const express = require('express');
const app = express();
app.use(express.json());
app.post('/auth/login', (req, res) => {
// validate against PostgreSQL
res.json({ token: 'jwt-token-here' });
});
app.listen(3000, '0.0.0.0', () => {
console.log('Server running on 0.0.0.0:3000');
});
Binding to 0.0.0.0 tells Node to accept connections from any network interface, not just localhost.
Step 3: Add Android Network Security Config
We created a network security config XML file to explicitly allow clear text traffic to our development IP:
<!-- android/app/src/main/res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTraffic="true">
<domain includeSubdomains="true">192.168.1.100</domain>
</domain-config>
</network-security-config>
This tells Android: it is okay to send HTTP traffic to 192.168.1.100 on port 3000. This is a development-only workaround.
Step 4: Reference Config in AndroidManifest
We linked the security config in the Android manifest:
<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
... >
...
</application>
</manifest>
Both android:networkSecurityConfig and android:usesCleartextTraffic are required for the config to take effect.
Step 5: Environment-Based API URLs in Flutter
We updated our Flutter config to use the correct IP based on the build mode:
// lib/config/api_config.dart
import 'package:flutter/foundation.dart' show kDebugMode;
class ApiConfig {
static String get baseUrl {
if (kDebugMode) {
// Use LAN IP for physical device, 10.0.2.2 for emulator
return 'http://192.168.1.100:3000';
}
return 'https://api.production.com';
}
}
In debug mode we always use the LAN IP. The emulator can reach it too, since 192.168.1.100 is a real address on the network.
Pitfalls We Would Warn an Intern About
Never Trust Emulator Behavior on Device
The emulator has special network routing that physical devices do not. Always test on real hardware early in development. We now require interns to run their first API call on a physical device within the first two days.
HTTP vs HTTPS on Android 9+
Clear text traffic is blocked by default in Android 9 (API 28+) Android Security Config. The security config workaround is only for development. Never ship a production app with cleartextTraffic="true".
IP Address Changes Break Everything
DHCP can change device IP addresses. We learned this the hard way when our Pixel 6 got a new IP after a router reboot and all API calls failed again.
Use static IP assignment in your router settings, or better yet, use mDNS for reliable local development.
Missing Error Handling Masks Real Issues
Timeout errors without context lead to wild goose chases. We now require all interns to add network logging in debug builds:
// lib/network/logger.dart
import 'package:flutter/services';
class NetworkLogger {
static void logRequest(String method, String url, Map<String, String> headers, String body) {
debugPrint('--> $method $url');
debugPrint('Headers: $headers');
debugPrint('Body: $body');
}
static void logResponse(int statusCode, Map<String, String> headers, String body) {
debugPrint('<-- $statusCode');
debugPrint('Headers: $headers');
debugPrint('Body: $body');
}
}
What We Would Do Differently Next Time
Use HTTPS from Day One
We now set up local SSL certificates for development using mkcert. This eliminates the need for network security config exceptions entirely.
# Install mkcert
brew install mkcert # macOS
# Create local CA
mkcert -install
# Generate cert for localhost
mkcert localhost 127.0.0.1 ::1
Implement Proper Environment Configuration
We moved to separate config files for dev, staging, and production using flutter_dotenv:
// lib/config/environment.dart
import 'package:flutter_dotenv/flutter_dotenv.dart';
class Environment {
static String get apiUrl => dotenv.env['API_URL'] ?? 'http://192.168.1.100:3000';
static String get environmentName => dotenv.env['ENV_NAME'] ?? 'development';
}
Add Network Debugging Tools
We integrated flutter_fimber for structured logging and use charles for proxy-based debugging during development.
Test on Physical Device Weekly
Not just at the end of the project. We now have a rule: every Friday, the team runs the app on a physical device and verifies all API calls work. This catches network issues before they become critical.
ISP-Level Gotchas We Discovered
While researching this issue, we found reports of ISP-level DNS blocking affecting Flutter apps in India. One developer documented that Reliance Jio and other Indian ISPs have blocked DNS resolution for certain backend domains LinkedIn post.
The quick fix was to set Private DNS to dns.google on the device. The professional solution was to hide the backend behind a reverse proxy on a custom domain.
This is a reminder that network issues are not always about your code. Sometimes they are about the network your users are on.
The Bottom Line
The emulator lies. It makes you think your network code works when it does not. Physical devices enforce real Android security policies that the emulator relaxes.
Always test on real hardware. Always add error handling. And never trust 10.0.2.2 outside the emulator.
Sources:
Sources
Related reading
Enjoyed this article?
Back to Blog


