The Current State of Flutter Logging (It's Bad)
I reviewed 50+ Flutter projects on GitHub last week. Want to know what I found? Every single one was using print()
statements as their primary debugging strategy.
Here's what "professional" Flutter logging looks like in 2025:
void handlePayment(double amount) {
print('Processing payment: \$${amount}');
try {
final result = processPayment(amount);
print('Payment successful!');
} catch (e) {
print('Error: $e'); // Good luck finding this in prod
}
}
If this looks familiar, you're part of the problem.
Why print() is Sabotaging Your Career
1. Production Invisibility
Your print()
statements disappear the moment you deploy. When your app crashes at 3 AM and your manager is asking questions, those debug prints won't save you.
2. Performance Killer
Every print()
call blocks the UI thread. You're literally making your app slower with every forgotten debug statement.
3. Zero Organization
Everything dumps to the same console. Finding relevant logs is like finding a specific grain of sand on a beach.
4. Memory Waste
Those string interpolations create objects that need garbage collection, even when logging is disabled.
5. Amateur Hour
Nothing screams "junior developer" like production code full of print()
statements.
Building a Real Logging System
When I built the logging system for Mosaic, I didn't just wrap print()
in a class. I engineered a production-ready system that handles real-world requirements.
Multi-Destination Logging
Different logs need different destinations:
await logger.init(
dispatchers: [
ConsoleDispatcher(), // Dev debugging
FileLoggerDispatcher(), // Persistent logs
RemoteLogDispatcher(), // Production monitoring
SlackDispatcher(), // Critical alerts
],
);
When network fails, file logging continues. When disk fills up, remote logging keeps working.
Hierarchical Level System
Stop thinking binary (log or don't). Use levels:
// Development: Everything
logger.setLogLevel(LogLevel.debug);
// Staging: Skip noise
logger.setLogLevel(LogLevel.info);
// Production: Problems only
logger.setLogLevel(LogLevel.warning);
Level filtering happens before processing, so higher levels = better performance.
Tag-Based Organization
Organize logs by context, not chaos:
logger.info('Login successful', ['auth', 'user_$id']);
logger.warning('Slow API: ${ms}ms', ['api', 'performance']);
logger.error('DB connection lost', ['database', 'critical']);
Now you can filter by component, user, or severity without parsing garbage.
Thread Safety That Actually Works
Most logging libraries aren't actually thread-safe. Multiple async operations logging simultaneously = corrupted output or race conditions.
My solution uses semaphore-based locking:
class ConsoleDispatcher extends LoggerDispatcher {
final _semaphore = Semaphore();
@override
Future<void> log(String message, LogType type, List<String> tags) async {
try {
await _semaphore.lock();
debugPrint(message); // Actually thread-safe now
} finally {
_semaphore.release();
}
}
}
Every dispatcher operation is mutex-protected. No more corrupted logs. You can also create your own dispatcher.
Rate Limiting Prevents Log Flooding
Your app hits an error loop and generates 50,000 logs per second. Congratulations, you just DoS'd yourself.
Real logging has rate limiting:
final _rateLimiter = RateLimiter(
window: Duration(seconds: 1),
maxRate: 1000,
);
if (_rateLimiter.exceeded()) {
debugPrint('Rate limit hit, dropping message');
return;
}
Protects performance while ensuring legitimate messages get through.
Async Processing Without Pain
Every log operation is async to avoid UI blocking:
Future<void> _dispatch(String message, LogType type, List<String> tags) async {
await Future.microtask(() async {
await Future.wait(
_dispatchers.values.map((d) => _safeDispatch(d, message, type, tags)),
);
});
}
But here's the key—bulletproof error handling:
Future<void> _safeDispatch(LoggerDispatcher dispatcher, ...) async {
try {
await dispatcher.log(message, type, tags)
.timeout(Duration(seconds: 5));
} catch (e) {
debugPrint('Dispatcher ${dispatcher.name} failed: $e');
// Auto-disable broken dispatchers
if (e is TimeoutException || e is IOException) {
dispatcher.active = false;
}
}
}
When dispatchers fail, logging continues. When network times out, file logging keeps working.
The Loggable Mixin
Instead of dependency injection everywhere, use a mixin with automatic context:
class PaymentService with Loggable {
@override
List<String> get loggerTags => ['payment'];
// Each log has this tag by default
Future<void> processPayment(double amount) async {
info('Processing payment: \$${amount}'); // Auto-tagged
try {
await _stripe.charge(amount);
info('Payment successful');
} catch (e) {
error('Payment failed: $e'); // Stands out as critical
rethrow;
}
}
}
Every message automatically includes class context. DRY, clean, scalable.
Memory Management That Won't Leak
Proper cleanup prevents production memory leaks:
Future<void> dispose() async {
if (_disposed) return;
_disposed = true;
// Clean up all dispatchers
await Future.wait(
_dispatchers.values.map((d) => d.dispose().catchError((_) {})),
);
// Clear all collections
_dispatchers.clear();
_defaultTags.clear();
_tags.clear();
}
Every resource tracked and cleaned up. No leaks, no surprises.
Stop Embarrassing Yourself
While you're debugging with print()
like it's 1995, professional developers use logging systems that actually work in production.
Systems with:
- Proper level filtering
- Tag-based organization
- Thread safety
- Rate limiting
- Error recovery
- Memory management
The complete logging implementation is available in the Mosaic framework along with modular architecture, thread-safe utilities, and other tools that separate professionals from hobbyists.
dependencies:
mosaic: ^0.0.3
Your future self (and your team) will thank you.
What's your biggest logging pain point? Drop a comment and let's discuss how to solve it properly.
Top comments (0)