Event Sourcing in a Mobile App: Lessons Learned
I integrated Event Sourcing and CQRS into Coachy, my strength-training app. Concrete benefits, pitfalls to avoid, and Flutter+NestJS implementation examples.
Event Sourcing on mobile solves 3 problems at once: complete history (every change is an event), natural offline sync (events are messages), and free analytics (event replay). The catch: the complexity of client-side projections.
Coachy tracks strength-training sessions. With a classic CRUD approach, editing a session erases the previous state. Synchronizing concurrent modifications becomes a nightmare. Analyzing the progression of an exercise over 6 months? Good luck with complex SQL queries.
Event Sourcing solves all three of these problems at once.
So I chose Event Sourcing (ES) coupled with CQRS. Not to follow a trend, but because tracking workouts is fundamentally a succession of events: "session started," "set added," "weight increased," "rest completed."
Why Event Sourcing for a mobile app
Event Sourcing is simple: instead of storing the current state, I store all the events that led to that state. Every user action becomes an immutable event.
In Coachy, a session is no longer a database row with "exercises: JSON." It's a sequence of events:
// Instead of this (CRUD){
id: "session_123",
exercises: [
{ name: "Squat", sets: [100, 100, 105] }
],
updatedAt: "2025-11-20T10:30:00Z"
}
// I store this (Event Sourcing)
[
{ type: "SessionStarted", sessionId: "123", timestamp: "10:00:00Z" },
{ type: "ExerciseAdded", sessionId: "123", exercise: "Squat", timestamp: "10:02:00Z" },
{ type: "SetCompleted", sessionId: "123", exercise: "Squat", weight: 100, reps: 10 },
{ type: "SetCompleted", sessionId: "123", exercise: "Squat", weight: 100, reps: 8 },
{ type: "WeightIncreased", sessionId: "123", exercise: "Squat", newWeight: 105 },
{ type: "SetCompleted", sessionId: "123", exercise: "Squat", weight: 105, reps: 6 }
]
This approach gives me three major advantages:
Complete history: I can answer "how long between my squat sets on October 15?" or "when did I increase my bench press weight?" The data is right there, in the events.
Natural replay: For debugging or analysis, I can reconstruct any past state. If a user reports a bug on a specific session, I replay their events and see exactly what happened.
Offline-first: My local source of truth accumulates events. When I sync, I just send the new events to the server. No complex merging, no conflicts on entire objects.
Event Sourcing shines particularly on mobile: the disconnected nature of mobile apps aligns perfectly with local event accumulation and deferred sync.
Flutter + NestJS architecture
On the Flutter side, I use a layered architecture inspired by Clean Architecture:
// Local Event Store (SQLite)class EventStore {
Future<void> append(String streamId, List<Event> events) async {
// Store events with sequential offset
for (var event in events) {
await db.insert('events', {
'stream_id': streamId,
'event_type': event.type,
'data': jsonEncode(event.data),
'timestamp': event.timestamp.toIso8601String(),
'version': await getNextVersion(streamId)
});
}
}
Stream<Event> getEvents(String streamId) {
return db.select('events')
.where('stream_id = ?', [streamId])
.orderBy('version')
.map((row) => Event.fromJson(row));
}
}
// Projection for views (CQRS)
class SessionProjection {
void handle(Event event) {
switch (event.type) {
case 'SessionStarted':
sessions[event.sessionId] = Session(
id: event.sessionId,
startTime: event.timestamp,
exercises: []
);
break;
case 'SetCompleted':
sessions[event.sessionId]?.addSet(
event.exercise,
event.weight,
event.reps
);
break;
}
}
}
On the NestJS side, I have a centralized Event Store with PostgreSQL and EventStore DB for optimal performance:
@Injectable()export class EventSourcingService {
async appendEvents(streamId: string, events: Event[]): Promise<void> {
const stream = await this.eventStore.readStream(streamId);
const expectedVersion = stream.length;
await this.eventStore.appendToStream(streamId, events, {
expectedVersion
});
// Projections updated asynchronously
await this.projectionService.updateProjections(streamId, events);
}
}
Concrete pitfalls encountered
Store size: With intensive use, you can easily hit 50k+ events in a few months. On mobile, that adds up. Hence the implementation of snapshots: every 1,000 events, I save the aggregated state to avoid replaying from the beginning.
class SnapshotStore { Future<Snapshot?> getLatestSnapshot(String streamId) async {
return db.select('snapshots')
.where('stream_id = ?', [streamId])
.orderBy('version DESC')
.limit(1)
.firstOrNull();
}
Future<void> saveSnapshot(String streamId, int version, Map<String, dynamic> state) async {
await db.insert('snapshots', {
'stream_id': streamId,
'version': version,
'state': jsonEncode(state),
'timestamp': DateTime.now().toIso8601String()
});
}
}
Synchronization: Handling concurrent events between mobile and server took longer than expected. I opted for a "last writer wins" strategy on non-conflicting events and manual resolution for real conflicts (rare in practice on a personal app).
Schema migrations: Evolving event formats without breaking the existing history requires discipline. I use an event versioning system:
interface Event { type: string;
version: number;
data: unknown;
timestamp: Date;
}
// Handling old versions
class EventMigrator {
migrate(event: Event): Event {
if (event.type === 'SetCompleted' && event.version < 2) {
// Migration v1 -> v2: added "restTime" field
return {
...event,
version: 2,
data: {
...event.data,
restTime: null // Default value
}
};
}
return event;
}
}
Event migration is critical: once in production, you can't change the past. Plan your schema evolutions from the start.
Performance and optimizations
Event Sourcing can become slow if poorly implemented. A few optimizations that saved me:
Smart indexing: Index on stream_id + version for fast replay, and on timestamp for analytical queries.
Materialized projections: Complex views (statistics, charts) are precomputed in dedicated tables, updated in the background.
Event compression: For repetitive sequences (10 identical sets), I have a BulkSetsCompleted event instead of 10 SetCompleted events.
Results in dev on Coachy: startup time <200ms, full sync <2s for 30 days of history, and zero data loss in my tests.
Development retrospective
Event Sourcing in Coachy is a bet that's paying off. Offline-first works naturally, no session is ever lost even in a crash, and I can implement advanced analytics features without monstrous SQL queries.
The trade-off? Higher cognitive complexity (thinking in events takes time) and more subtle debugging (you need to understand the sequence of events, not just the final state).
But for a mobile app where user data is critical and offline is frequent, Event Sourcing + CQRS remains my architecture of choice. Especially when you're developing solo and need a solid foundation to build on.
Also worth reading
- Offline-First Architecture: Patterns and Pitfalls
- NestJS for a Solo Dev: Why I Chose This Stack
- Building Products on the Side
Sources: