Skip to content
Umar ManzoorBook a call
← Writing

Your App Shouldn't Need to Know Everything That Happens

July 21, 2026

Picture a delivery app.

The driver moves. The map needs to re-center. The ETA changes. The order status flips from Preparing to On the way. A push notification might need to fire. Analytics wants to record the movement. If the user is offline, the sync layer needs to reconcile later. And somewhere, a geofence is quietly waiting to notice the driver has arrived.

Now the question that actually decides whether this app stays maintainable: when the driver's location changes, how many parts of your codebase find out — and how?

If the tracking code directly calls the map, then the ETA calculator, then the notification service, then analytics, then the cache, you've built something that works today and fights you forever. Every new consumer means editing the tracking code. Every change to a consumer risks the tracking code. The thing producing the information ends up knowing about everything that consumes it.

That coupling is the real problem. Event-driven communication is one of the cleaner ways out of it — but only if you're honest about what it's for.

The problem isn't calling functions. It's knowing too much.

Here's the tightly coupled version, drawn out:

Tracking ──▶ OrderScreen ──▶ Notifications ──▶ Analytics

It looks harmless. It's a chain of calls. But look at what each arrow implies: the tracking system now imports and depends on the order screen. The order screen depends on the notification system. Change the notification API and you're editing tracking code that has nothing to do with notifications.

The tracking system's job is to know where the driver is. That's it. It should not need to know:

  • which screen is currently open
  • which notification should be shown
  • which analytics event to record
  • which cache to invalidate

The moment it knows those things, it can't be reused, can't be tested in isolation, and can't be changed without a ripple. Multiply this by every feature that produces information other features care about, and you get the app everyone describes the same way: "it's hard to change."

Publish what happened. Let the interested parts react.

The idea behind event-driven communication is small: one part of the system announces that something happened, and any part that cares subscribes to it. The publisher doesn't hold a list of consumers. It doesn't know they exist.

                DriverLocationUpdated
                        │
                   ┌────┴────┬─────────┬──────────┐
                   ▼         ▼         ▼          ▼
                  Map       ETA     Geofence   Analytics

The tracking system publishes DriverLocationUpdated and moves on. The map re-centers. The ETA recalculates. The geofence checks whether the driver crossed a boundary. Analytics records it. None of them know about each other, and the tracking system knows about none of them.

That inversion — the producer no longer owning its consumers — is the entire point. Everything else is mechanics.

The distinction that saves you: events vs state

Before any code, the single most useful idea in this whole space:

An event says "something happened." A state says "what is true right now."

Events:                        State:
  PaymentCompleted               CurrentDriverLocation
  DriverArrived                  CurrentOrderStatus
  UserLoggedOut                  CurrentUser
  NetworkConnected               IsAuthenticated

Events describe changes. State describes the current reality after those changes. They are not interchangeable, and conflating them is where a lot of "reactive" codebases quietly rot.

The relationship is a pipeline:

DriverLocationUpdated   ← event ("this just happened")
        │
        ▼
update CurrentDriverLocation   ← state ("this is now true")
        │
        ▼
       UI

Why does this matter in practice? Because they have different lifecycles and different failure modes.

A screen that opens after the driver already moved doesn't care that a DriverLocationUpdated event fired two seconds ago — it missed it, and that's fine. What it needs is the current location, right now, to draw the map. That's state. But an event like PaymentCompleted should fire exactly once and be handled exactly once — replaying it because a screen re-subscribed would be a bug (you don't want to show "Payment successful" twice or, worse, trigger a side effect twice).

So the rule of thumb:

  • "What is true now?" → hold it as state. New subscribers get the current value immediately.
  • "Something happened, react once." → send it as an event. Late subscribers should not get the old one.

Keep that in your head as we look at both platforms, because both give you two different primitives for exactly this reason.

Flutter: broadcast streams for events, StreamBuilder for state

Dart's Stream is the natural building block. Model events as a sealed hierarchy so the compiler helps you:

sealed class DeliveryEvent {}

final class DriverLocationUpdated extends DeliveryEvent {
  final LatLng position;
  final String orderId;
  DriverLocationUpdated(this.position, this.orderId);
}

final class OrderStatusChanged extends DeliveryEvent {
  final String orderId;
  final OrderStatus status;
  OrderStatusChanged(this.orderId, this.status);
}

A minimal, typed event channel is just a broadcast controller:

class DeliveryEventBus {
  final _controller = StreamController<DeliveryEvent>.broadcast();

  Stream<DeliveryEvent> get events => _controller.stream;

  void publish(DeliveryEvent event) => _controller.add(event);

  void dispose() => _controller.close();
}

broadcast matters: a single-subscription stream lets one listener attach. A broadcast stream lets many — which is what "several features react to one event" requires.

Publishing is boring, which is the goal:

bus.publish(DriverLocationUpdated(position, orderId));

Reacting stays strongly typed thanks to the sealed class:

bus.events
   .whereType<DriverLocationUpdated>()
   .listen((event) => mapController.moveTo(event.position));

Now the important half that tutorials skip. For rendering current state, don't wire the raw event stream into your widgets. Fold events into a state value and let StreamBuilder render that:

StreamBuilder<LatLng>(
  stream: trackingState.currentLocation, // a state stream, not the event bus
  builder: (context, snapshot) {
    final position = snapshot.data;
    if (position == null) return const _MapLoading();
    return DeliveryMap(position: position);
  },
);

This is the events-vs-state distinction expressed in code: the event bus carries DriverLocationUpdated; a state holder collapses those into currentLocation; the widget renders the state.

A few things that will bite you if you ignore them:

  • Subscriptions leak. Every .listen() returns a StreamSubscription. If you open one in a widget, cancel it in dispose(). StreamBuilder manages its own subscription, which is one reason it's the safer choice for UI.
  • Widget lifecycle is not event lifecycle. A widget can be rebuilt or destroyed at any time. An event fired while a screen is gone is simply not observed — which is correct for events and wrong for state (hence the state holder).
  • Broadcast streams don't replay. A listener that attaches late misses everything before it. Again: fine for events, a bug if you were secretly relying on it for state.

And the question you should ask before any of this: does this even need a global bus? If two widgets in the same screen need to talk, a shared ValueNotifier or a scoped state object is simpler, more traceable, and easier to delete. Reach for a bus when the producer and consumers are genuinely in different parts of the app and shouldn't know about each other. Not before.

Android: SharedFlow for events, StateFlow for state

Kotlin makes the distinction almost official, because it ships two different types.

SharedFlow is a hot stream for events. StateFlow always holds a current value — it's state. Same two concepts, first-class.

Events flow in through a SharedFlow:

class DeliveryEvents {
    private val _events = MutableSharedFlow<DeliveryEvent>(extraBufferCapacity = 16)
    val events: SharedFlow<DeliveryEvent> = _events.asSharedFlow()

    suspend fun publish(event: DeliveryEvent) = _events.emit(event)
}

The ViewModel consumes events and reduces them into StateFlow:

class TrackingViewModel(events: DeliveryEvents) : ViewModel() {

    private val _location = MutableStateFlow<LatLng?>(null)
    val location: StateFlow<LatLng?> = _location.asStateFlow()

    init {
        events.events
            .filterIsInstance<DriverLocationUpdated>()
            .onEach { _location.value = it.position }
            .launchIn(viewModelScope)
    }
}

The UI collects state — and, crucially, collects it lifecycle-aware so you don't keep updating a screen that isn't visible:

lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.location.collect { position ->
            renderMap(position)
        }
    }
}

The whole shape:

External event ─▶ SharedFlow ─▶ ViewModel ─▶ StateFlow ─▶ UI

Put side by side with Flutter, it's the same architecture with different names:

  • Flutter broadcast Stream of events ≈ Kotlin SharedFlow
  • A Flutter state stream / ValueNotifier ≈ Kotlin StateFlow
  • StreamBuildercollect inside repeatOnLifecycle

If you understand the events-vs-state split, you already understand both platforms.

The real example: delivery tracking end to end

Here's the flow the whole article is really about:

WebSocket / Firebase / real-time source
        │
        ▼
   location update
        │
        ▼
  DriverLocationUpdated   (event)
        │
        ▼
   event processing
        │
        ▼
 CurrentDriverLocation    (state)
        │
        ▼
       UI

A raw location arrives from a socket. It becomes a DriverLocationUpdated event. Interested parts react:

  • the map re-centers on the new position
  • the ETA recalculates against the remaining route
  • analytics records the movement for later
  • a geofence checks whether this position crosses the "arrived" boundary and, if so, publishes a new event — DriverArrived — which the notification layer can pick up

Notice what didn't happen: the socket layer never learned that a map exists, or that analytics exists, or that there's a geofence. It published one fact. The reactions composed themselves. When you add a new consumer next quarter — say, a support dashboard — you subscribe to the event and touch nothing upstream.

Connectivity is an event too

Event-driven thinking isn't only for real-time server data. Some of the highest-leverage events in a mobile app are about the device itself.

NetworkDisconnected   ──▶  pause uploads, show offline banner
NetworkConnected      ──▶  retry failed requests, start sync,
                           upload pending actions, refresh stale data

Connectivity changes are classic events: they happen, several unrelated features care, and none of them should be reaching into the others. The retry logic, the sync worker, and the "you're back online" banner can each subscribe to NetworkConnected independently. The code that detects connectivity has no idea any of them exist.

Offline-first: where this really pays off

Offline-first apps are where event-driven boundaries stop being elegant and start being necessary.

OrderCreatedOffline
        │
        ▼
   Local database
        │
        ▼
   Pending sync queue
        │
   NetworkConnected   (event)
        │
        ▼
    Sync worker ─▶ Backend

The user creates an order with no signal. You persist it locally and record that it's pending. That's it — the UI can move on. Later, NetworkConnected fires, the sync worker wakes up, drains the queue, and reconciles with the backend.

What this buys you is a clean separation between four things that usually get tangled:

  • user actions (create the order)
  • local persistence (write it down)
  • synchronization (push it when possible)
  • UI state (reflect "pending" then "confirmed")

None of these needs to call the others directly. The action produces a record and an event. The sync layer reacts to connectivity. The UI reflects state. Each can be tested and changed on its own.

Payments and logout: one event, many honest reactions

PaymentCompleted is a good example of an event with genuinely independent consumers:

  • order state moves to Paid
  • wallet/balance state updates
  • analytics records the conversion
  • the UI shows a receipt

Four features, one event, zero direct calls between them. Adding a fifth (a loyalty-points feature, say) means one new subscriber.

UserLoggedOut is the one people always get wrong. The tempting move is a giant LogoutService that imports every feature and tears each one down. Then logout knows about everything, and every new feature has to remember to edit logout.

Instead, publish UserLoggedOut and let each concern clean up after itself:

  • the cache layer clears itself
  • the WebSocket manager disconnects
  • the local database wipes user data
  • navigation resets to the auth screen
  • background tasks cancel

Each of these already owns its resource. It should own its own teardown too. Logout becomes a fact you announce, not a service that reaches into every corner of the app.

Now the part that's easy to get wrong

Everything above makes event-driven communication sound like an unqualified win. It isn't, and a senior review should be suspicious of anyone who says it is.

A badly designed global event bus becomes a hidden dependency system — arguably worse than the tight coupling it replaced, because at least direct calls are visible in the code. With a sloppy global bus, you can't answer basic questions:

  • Who publishes this event?
  • Who consumes it?
  • What happens if it's missed?
  • Can it be processed twice?
  • What happens when the screen that was listening is destroyed mid-handling?
  • What is this event's lifecycle — fire-and-forget, or does someone rely on it later?

If you can't answer those for a given event, you don't have an architecture; you have a message-shaped goto. "Grep for the string and hope you found all the listeners" is not a design.

So use it intentionally, with boundaries:

  • Type your events (sealed classes / sealed interfaces). No stringly-typed buses.
  • Scope aggressively. Most communication belongs inside a feature or screen, not on a global bus. A bus is for crossing boundaries that should be crossed loosely.
  • Be deliberate about events vs state. One-shot side effects are events. Current reality is state. Don't render UI off a raw event stream, and don't replay side effects.
  • Own lifecycles. Cancel subscriptions. Collect state lifecycle-aware. Decide, per event, what "missed" and "twice" mean.

The actual point

The goal is not to put everything on a global event bus. A global bus that everything talks through is just tight coupling wearing a costume.

The goal is narrower and more valuable: to let the right parts of an application communicate without forcing them to know too much about each other. A delivery tracker should be able to say "the driver moved" without holding a directory of everyone who cares. A logout should be able to say "the user left" without a service that reaches into every feature.

Reduce what each part has to know about the rest, and the app gets easier to change — which, in the end, is the only architectural property that consistently matters.