🧵 How I Fixed a Race Condition in Dart Using the `synchronized` Package

Race conditions in Dart aren't always obvious — until your app starts misbehaving in weird, inconsistent ways.
I recently encountered a bug where two async methods were stepping on each other. The solution? A tiny but powerful Dart package: synchronized
.
Let’s walk through the problem and how I fixed it.
🚨 The Problem: Async ≠ Safe
Here’s the setup:
Future<void> start() async {
// setup logic
}
Future<void> stop() async {
// cleanup logic
}
Now imagine these get called almost at the same time — maybe from different parts of the app:
start(); // Not awaited
// on a different part of the app
stop(); // Not awaited either
This is fire-and-forget. But the problem is: since both methods contain await
inside, they run concurrently. That means stop()
could finish before start()
even begins.
In some scenarios, this leads to:
Incomplete setup
Conflicting state
Errors in backend or SDKs (like tracing tools)
✅ The Fix: Use Lock
from the synchronized
Package
The synchronized
package lets you define mutual exclusion — so one block of code can run at a time.
import 'package:synchronized/synchronized.dart';
final Lock _lock = Lock();
Future<void> start() {
return _lock.synchronized(() async {
// guaranteed to not overlap with stop()
await Future.delayed(Duration(milliseconds: 100));
print("Started");
});
}
Future<void> stop() {
return _lock.synchronized(() async {
await Future.delayed(Duration(milliseconds: 50));
print("Stopped");
});
}
Now, even though start()
and stop()
are not awaited by the caller, they won’t run at the same time. The lock ensures that.
🔑 Scoped Locks for More Granular Control
What if you have multiple instances — like sessions, files, or user IDs — and want to isolate each one?
This pattern helps:
final Map<String, Lock> _locks = {};
Lock _getLock(String key) {
return _locks.putIfAbsent(key, () => Lock());
}
Future<void> doSomething(String key) {
return _getLock(key).synchronized(() async {
// only one task per key can run at a time
print("Running $key");
await Future.delayed(Duration(milliseconds: 100));
});
}
So doSomething("A")
and doSomething("B")
can run in parallel — but two calls to "A"
will wait in line.
⚙️ Real-World Use Cases
Here’s where Lock
becomes super useful:
Managing start/stop lifecycles
Preventing duplicate API calls
Controlling access to local storage
Logging or analytics events
Database writes per key/session
💡 Takeaways
Dart async gives you concurrency, not thread safety
Use
synchronized
to prevent race conditions in fire-and-forget callsScoped locks let you maintain performance and correctness
Easy to add, powerful in practice
If you've faced subtle bugs caused by async functions clashing with each other — try out synchronized
.
Subscribe to my newsletter
Read articles from fahmi sidik directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

fahmi sidik
fahmi sidik
Mobile-Focused Software Engineer with combined 10 years of experience in Android, iOS, Flutter, and CI/CD. I have worked on feature development, performance optimization, native to Flutter migration, automation, and developer toolings.