Skip to content
Commitwise
Menu
Design Patterns

Observer

Let objects react to changes without knowing who is listening, from simple listeners to NotificationCenter, Combine, StateFlow, Spring events and RxJS, with examples in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

When the user adds a product to the cart, the badge on the tab bar must show the new count, the cart screen must reload, the checkout button must enable itself, and analytics must log the event. If the cart calls each of those directly, the cart now knows about tab bars, screens, buttons and analytics, and every new feature means editing the cart again.

The Observer pattern turns that around: the cart announces “I changed”, and whoever cares is listening.

New to some of these? Open what you need, or skip ahead.

Basics:What is a design pattern?

A design pattern is a named, reusable solution to a problem that keeps coming up in software design, like “swap one algorithm for another” or “notify many objects of a change”. It’s not a library you install; it’s a way of arranging classes and interfaces that experienced developers recognize.

Knowing the names helps in two ways: you recognize the pattern in code you read (Android, iOS and Spring use many of them), and you can discuss designs quickly in reviews and interviews (“let’s use a Strategy here”).

Basics:What is an interface (or protocol)?

An interface (Kotlin, Java) or protocol (Swift) is a list of things a type promises it can do, without saying how. For example, “anything that is a PaymentMethod has a pay(amount) function”.

Different classes can implement the same interface in different ways (card, PayPal, cash), and code that only knows the interface can use any of them. That’s what lets you swap one implementation for another, for example a fake one in tests.

Basics:What are lambdas and closures?

A lambda (Kotlin, Java) or closure (Swift) is a small function without a name that you can store in a variable or pass to another function:

Swift:   numbers.filter { $0 > 10 }           // $0 is the first parameter
Kotlin:  numbers.filter { it > 10 }           // it is the single parameter
Java:    numbers.stream().filter(n -> n > 10)
TS:      numbers.filter((n) => n > 10)

The code inside the braces (or after ->) runs later, when the function you passed it to decides to call it: once per number here, or when a button is tapped in UI code. A closure can also use variables from the code around it.

Basics:What is an API?

An API (application programming interface) is the set of operations a system offers to other programs. In app development it usually means a web API: a server with URLs your app calls to read and change data, like GET /api/products to list products.

The API is a contract: the app relies on its URLs, fields and status codes. Changing them carelessly breaks apps that are already installed on people’s phones.

Part Not started

From direct calls to observers

Think of subscribing to a YouTube channel. The channel doesn’t keep a list of people to phone when a new video is out. You press Subscribe, and when a video is published, every subscriber gets a notification. You can unsubscribe at any time, and the channel doesn’t care how many subscribers it has or who they are.

In code:

  1. the subject (the cart) keeps a list of observers, also called listeners or subscribers,
  2. observers subscribe to be told about changes, and unsubscribe when they stop caring,
  3. when something changes, the subject notifies every observer on the list.

Observer decouples a subject from an open set of dependents: the subject publishes changes through a narrow interface, and observers register and unregister at runtime. You rarely hand-roll it today; it’s the foundation under NotificationCenter, KVO, Combine and the Observation framework on Apple platforms, listeners, LiveData, Flow/StateFlow and Compose state on Android, and java.beans, java.util.concurrent.Flow, Reactor and Spring application events on the server; in TypeScript, the DOM’s addEventListener, Node’s EventEmitter, RxJS and the signals in modern UI frameworks.

The design questions are the same everywhere: state or events (does a late subscriber get the current value?), push or pull, which thread observers run on and under which locks, and who owns the subscription’s lifetime. The Detailed version goes through each, with the production bugs they cause.

Before: The cart knows every screen

final class Cart {
    private(set) var items: [Product] = []
    weak var badge: CartBadgeView?
    weak var cartScreen: CartViewController?
    weak var checkoutButton: CheckoutButton?

    func add(_ product: Product) {
        items.append(product)
        badge?.update(count: items.count)
        cartScreen?.reload()
        checkoutButton?.isEnabled = true
        Analytics.log("cart_add", product.id)
    }
}
class Cart(
    private val badge: CartBadge,
    private val cartScreen: CartScreen,
    private val checkoutButton: CheckoutButton,
) {
    private val items = mutableListOf<Product>()

    fun add(product: Product) {
        items += product
        badge.update(items.size)
        cartScreen.reload()
        checkoutButton.isEnabled = true
        Analytics.log("cart_add", product.id)
    }
}
class Cart {
    private final List<Product> items = new ArrayList<>();
    private final CartBadge badge;
    private final CartScreen cartScreen;
    private final CheckoutButton checkoutButton;

    Cart(CartBadge badge, CartScreen cartScreen, CheckoutButton checkoutButton) {
        this.badge = badge; this.cartScreen = cartScreen; this.checkoutButton = checkoutButton;
    }

    void add(Product product) {
        items.add(product);
        badge.update(items.size());
        cartScreen.reload();
        checkoutButton.setEnabled(true);
        Analytics.log("cart_add", product.id());
    }
}
class Cart {
  private readonly items: Product[] = [];

  constructor(
    private readonly badge: CartBadge,
    private readonly cartScreen: CartScreen,
    private readonly checkoutButton: CheckoutButton,
  ) {}

  add(product: Product) {
    this.items.push(product);
    this.badge.update(this.items.length);
    this.cartScreen.reload();
    this.checkoutButton.enabled = true;
    analytics.log("cart_add", product.id);
  }
}

After: The cart announces changes; listeners decide

final class Cart {
    private(set) var items: [Product] = []
    private var observers: [UUID: ([Product]) -> Void] = [:]

    /// Returns a token; the subscription ends when the token is cancelled.
    func observe(_ onChange: @escaping ([Product]) -> Void) -> Subscription {
        let id = UUID()
        observers[id] = onChange
        onChange(items)                                // send the current state right away
        return Subscription { [weak self] in self?.observers[id] = nil }
    }

    func add(_ product: Product) {
        items.append(product)
        observers.values.forEach { $0(items) }
    }
}

final class Subscription {
    private let onCancel: () -> Void
    init(_ onCancel: @escaping () -> Void) { self.onCancel = onCancel }
    func cancel() { onCancel() }
    deinit { onCancel() }                              // released token = unsubscribed
}

// Any screen can listen, and the cart doesn't know it exists:
subscription = cart.observe { [weak self] items in
    self?.badgeLabel.text = "\(items.count)"
}
class Cart {
    private val items = mutableListOf<Product>()
    private val listeners = CopyOnWriteArrayList<(List<Product>) -> Unit>()

    fun addListener(listener: (List<Product>) -> Unit): () -> Unit {
        listeners += listener
        listener(items.toList())                       // send the current state right away
        return { listeners -= listener }               // call it to unsubscribe
    }

    fun add(product: Product) {
        items += product
        val snapshot = items.toList()
        listeners.forEach { it(snapshot) }
    }
}

// Any screen can listen, and the cart doesn't know it exists:
val unsubscribe = cart.addListener { items -> badge.text = items.size.toString() }
interface CartListener {
    void cartChanged(List<Product> items);
}

class Cart {
    private final List<Product> items = new ArrayList<>();
    private final List<CartListener> listeners = new CopyOnWriteArrayList<>();

    Runnable addListener(CartListener listener) {
        listeners.add(listener);
        listener.cartChanged(List.copyOf(items));      // send the current state right away
        return () -> listeners.remove(listener);       // run it to unsubscribe
    }

    void add(Product product) {
        items.add(product);
        List<Product> snapshot = List.copyOf(items);
        listeners.forEach(l -> l.cartChanged(snapshot));
    }
}

// Any screen can listen, and the cart doesn't know it exists:
Runnable unsubscribe = cart.addListener(items -> badge.setText(String.valueOf(items.size())));
type CartListener = (items: readonly Product[]) => void;

class Cart {
  private items: Product[] = [];
  private readonly listeners = new Set<CartListener>();

  /** Returns a function; call it to unsubscribe. */
  addListener(listener: CartListener): () => void {
    this.listeners.add(listener);
    listener([...this.items]);                       // send the current state right away
    return () => this.listeners.delete(listener);
  }

  add(product: Product) {
    this.items = [...this.items, product];
    const snapshot = [...this.items];
    for (const listener of this.listeners) listener(snapshot);
  }
}

// Any component can listen, and the cart doesn't know it exists:
const unsubscribe = cart.addListener((items) => (badge.textContent = String(items.length)));

What changed:

  • The cart knows nothing about screens. It has a list of listeners and calls them. A new feature (say, a “free shipping at 50 EUR” banner) subscribes on its own, and the cart doesn’t change.
  • Listeners get a snapshot, a copy of the items, so they can’t change the cart’s list by accident.
  • Every subscription can end. Subscribing returns something you call (or release) to unsubscribe. That turns out to be the most important part, as the next bug shows.

Observer in the platforms you use

You’ve already used this pattern, maybe without the name:

PlatformObserver in practice
iOSNotificationCenter, @Observable and SwiftUI updates, Combine’s @Published and sink, KVO, delegates with one observer
AndroidClick listeners, LiveData.observe, StateFlow.collect, Compose recomposition when state changes
Java serverSpring @EventListener and ApplicationEventPublisher, PropertyChangeListener, message queues (observers in other processes)
TypeScriptaddEventListener in the browser, Node’s EventEmitter, RxJS subscribe, React re-rendering when state changes, signals

Choosing the observable type

NeedSwiftKotlinJava / SpringTypeScript
UI state with a current value@Observable (pull-based tracking), @PublishedStateFlow (conflated, dedups with equals)PropertyChangeSupport, Reactor Sinks.many().replay().latest()RxJS BehaviorSubject, signals, a store read with useSyncExternalStore
One-off events, each delivered onceAsyncStream, PassthroughSubjectChannel + receiveAsFlow(), SharedFlow(replay = 0)ApplicationEventPublisher, Sinks.many().multicast()EventEmitter, EventTarget, RxJS Subject
Cross-module broadcastNotificationCenter (stringly typed)a shared SharedFlow in a DI-scoped busSpring events, a message broker between servicesBroadcastChannel (between tabs), a module-level emitter
BackpressureAsyncStream buffering policybuffer, conflate, collectLatestjava.util.concurrent.Flow / Reactive Streams request(n)RxJS throttleTime, sampleTime, bufferTime; async iterators

The Observation framework is worth calling out: @Observable doesn’t push values at all. SwiftUI (or withObservationTracking) records which properties a closure read, and gets a single “will change” callback when one of them changes, then re-reads. Pull-based tracking means no subscriptions to manage and no over-notification, but also no stream of values: if you need every intermediate value, use an AsyncStream.

Threads, locks and reentrancy

Synchronous observers run inside the subject’s call: on its thread, under its locks, before it returns. Consequences:

  • Notify after releasing locks (puzzle below), on a documented thread or executor. UI observers must hop to the main thread; on Apple platforms, isolate UI models to @MainActor.
  • An observer that calls back into the subject sees a half-updated state if notification happens mid-mutation. Mutate fully, then notify.
  • One slow or throwing observer shouldn’t break the others: isolate failures per observer (catch and log) or dispatch asynchronously.
  • In JavaScript there are no locks to hold, but the reentrancy point stands: an observer that calls back into the subject runs in the middle of the update. And an observer that throws stops the loop for the ones after it, unless you catch per observer.
  • Ordering between observers is unspecified unless you specify it. Don’t build features that rely on “the analytics listener runs first”.

Notifying under a lock

Spot the bugAn inventory that deadlocks under load

Tap the line with the bug.

final class Inventory: @unchecked Sendable {
    private let lock = NSLock()
    private var stock: [String: Int] = [:]
    private var observers: [(String, Int) -> Void] = []

    func remove(_ sku: String, quantity: Int) {
        lock.withLock {
            let left = (stock[sku] ?? 0) - quantity
            stock[sku] = left
            observers.forEach { $0(sku, left) }
        }
    }
}
class Inventory {
    private val lock = ReentrantLock()
    private val stock = HashMap<String, Int>()
    private val observers = CopyOnWriteArrayList<(String, Int) -> Unit>()

    fun remove(sku: String, quantity: Int) = lock.withLock {
        val left = (stock[sku] ?: 0) - quantity
        stock[sku] = left
        observers.forEach { it(sku, left) }
    }
}
class Inventory {
    private final Map<String, Integer> stock = new HashMap<>();
    private final List<BiConsumer<String, Integer>> observers = new CopyOnWriteArrayList<>();

    synchronized void remove(String sku, int quantity) {
        int left = stock.getOrDefault(sku, 0) - quantity;
        stock.put(sku, left);
        observers.forEach(o -> o.accept(sku, left));
    }
}

  • Notify with a snapshot of the data and of the listener list, after the mutation is complete and outside any lock, on a documented thread.
  • Synchronous observers run inside the subject’s call: its thread, its locks, before it returns. Isolate slow or throwing observers.
SwiftKotlinJavaTypeScript
Hand-rolled subscriptiontoken (AnyCancellable, your own)returned unsubscribe functionreturned handle / remove…Listenerreturned unsubscribe function
Platform observers@Observable, @Published, NotificationCenterStateFlow, SharedFlow, listenersSpring events, PropertyChangeSupport, Flow.PublisherEventTarget, Node EventEmitter, RxJS, signals

Swift

@Observable is pull-based: SwiftUI records which properties a view read and gets one “will change” callback, with no subscriptions to manage and no stream of values (use AsyncStream for that).

TypeScript

There are no locks, but reentrancy remains: an observer that calls back into the subject during notification sees a half-updated state. Mutate fully, then notify.

Check this part

  1. After the refactor, what does the cart know about the screens that show it?

  2. Why do listeners receive a copy (snapshot) of the items?

  3. Observers are called while the subject holds its lock. What can go wrong?Observers are called inside a synchronized method. What can go wrong?JavaScript has no threads here. What can still go wrong when a subject notifies observers in the middle of an update?

  4. Where do synchronous observers run?

The subject keeps a list of observers and notifies them when it changes, without knowing who they are.

Part Not started

The listener that never leaves

Spot the bugScreens that live forever

Tap the line with the bug.

final class CartBadgeViewController: UIViewController {
    private let label = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        _ = cart.observe { items in
            self.label.text = "\(items.count)"
        }
    }
}
class CartBadgeFragment : Fragment(R.layout.cart_badge) {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val badge = view.findViewById<TextView>(R.id.badge)
        cart.addListener { items ->
            badge.text = items.size.toString()
        }
    }
}
public class CartBadgeFragment extends Fragment {

    @Override
    public void onViewCreated(View view, Bundle savedInstanceState) {
        TextView badge = view.findViewById(R.id.badge);
        cart.addListener(items -> badge.setText(String.valueOf(items.size())));
    }
}
function useCartCount(): number {
  const [count, setCount] = useState(0);
  useEffect(() => {
    cart.addListener((items) => setCount(items.length));
  }, []);
  return count;
}

Practice: a reusable observable value

Most apps need “a value that others can watch” many times. Let’s build one, step by step:

  1. Store the current value and a collection of observers.
  2. observe adds an observer, calls it once with the current value, and returns a way to unsubscribe.
  3. Setting the value calls every observer, but only if the value actually changed.
final class ObservableValue<T: Equatable> {
    private var observers: [UUID: (T) -> Void] = [:]

    var value: T {
        didSet {
            guard value != oldValue else { return }
            observers.values.forEach { $0(value) }
        }
    }

    init(_ value: T) { self.value = value }

    func observe(_ onChange: @escaping (T) -> Void) -> Subscription {
        let id = UUID()
        observers[id] = onChange
        onChange(value)
        return Subscription { [weak self] in self?.observers[id] = nil }
    }
}

let isOnline = ObservableValue(true)
let sub = isOnline.observe { print($0 ? "Online" : "Offline") }   // Online
isOnline.value = false                                             // Offline
isOnline.value = false                                             // nothing: no change
class ObservableValue<T>(initial: T) {
    private val observers = CopyOnWriteArrayList<(T) -> Unit>()

    var value: T = initial
        set(newValue) {
            if (newValue == field) return
            field = newValue
            observers.forEach { it(newValue) }
        }

    fun observe(onChange: (T) -> Unit): () -> Unit {
        observers += onChange
        onChange(value)
        return { observers -= onChange }
    }
}

val isOnline = ObservableValue(true)
val stop = isOnline.observe { println(if (it) "Online" else "Offline") }   // Online
isOnline.value = false                                                      // Offline
isOnline.value = false                                                      // nothing: no change
class ObservableValue<T> {
    private final List<Consumer<T>> observers = new CopyOnWriteArrayList<>();
    private T value;

    ObservableValue(T initial) { value = initial; }

    T get() { return value; }

    void set(T newValue) {
        if (Objects.equals(newValue, value)) return;
        value = newValue;
        observers.forEach(o -> o.accept(newValue));
    }

    Runnable observe(Consumer<T> onChange) {
        observers.add(onChange);
        onChange.accept(value);
        return () -> observers.remove(onChange);
    }
}
class ObservableValue<T> {
  private readonly observers = new Set<(value: T) => void>();

  constructor(private current: T) {}

  get value(): T {
    return this.current;
  }

  set value(next: T) {
    if (Object.is(next, this.current)) return;     // no change: no notification
    this.current = next;
    for (const observer of this.observers) observer(next);
  }

  observe(onChange: (value: T) => void): () => void {
    this.observers.add(onChange);
    onChange(this.current);
    return () => this.observers.delete(onChange);
  }
}

const isOnline = new ObservableValue(true);
const stop = isOnline.observe((online) => console.log(online ? "Online" : "Offline"));   // Online
isOnline.value = false;                                                                   // Offline
isOnline.value = false;                                                                   // nothing: no change

This is a tiny version of StateFlow, @Published and RxJS’s BehaviorSubject: a current value, deduplication of equal values, and subscriptions that can end. In real projects, use those, but now you know what they do.

Why does the Kotlin and Java version use CopyOnWriteArrayList instead of a normal list? Look at what happens with a normal one:

Spot the bugA one-time listener that crashes the app

Tap the line with the bug.

class Downloads {
    private val listeners = mutableListOf<DownloadListener>()

    fun add(listener: DownloadListener) { listeners += listener }
    fun remove(listener: DownloadListener) { listeners -= listener }

    private fun notifyFinished(file: File) {
        for (listener in listeners) listener.finished(file)
    }
}

// Somewhere else: show a toast for the first finished download only
downloads.add(object : DownloadListener {
    override fun finished(file: File) {
        showToast("Downloaded " + file.name)
        downloads.remove(this)
    }
})
class Downloads {
    private final List<DownloadListener> listeners = new ArrayList<>();

    void add(DownloadListener listener) { listeners.add(listener); }
    void remove(DownloadListener listener) { listeners.remove(listener); }

    private void notifyFinished(File file) {
        for (DownloadListener listener : listeners) listener.finished(file);
    }
}

// Somewhere else: show a toast for the first finished download only
downloads.add(new DownloadListener() {
    public void finished(File file) {
        showToast("Downloaded " + file.getName());
        downloads.remove(this);
    }
});

Exerciseeasy

Download progress

A Download reports progress from 0 to 100 and then finishes with a file (or fails with an error). Two parts of the app watch it: a progress bar on screen and a system notification. Write the Download type so that:

  • any number of observers can subscribe and unsubscribe,
  • an observer that subscribes halfway immediately sees the current progress,
  • observers learn about completion and failure, not just progress.
›

Model what an observer can see as one type: inProgress(percent), finished(file), failed(error). Then observers receive that type, and a late subscriber gets the current one.

›

Reuse the observable value from the practice section: the download’s state is an observable value.

SolutionTry it yourself first
enum DownloadState: Equatable {
    case inProgress(percent: Int)
    case finished(URL)
    case failed(message: String)
}

final class Download {
    private let state = ObservableValue<DownloadState>(.inProgress(percent: 0))

    func observe(_ onChange: @escaping (DownloadState) -> Void) -> Subscription {
        state.observe(onChange)
    }

    // Called by the networking code:
    func report(percent: Int) { state.value = .inProgress(percent: percent) }
    func finish(at file: URL) { state.value = .finished(file) }
    func fail(_ message: String) { state.value = .failed(message: message) }
}

let bar = download.observe { state in
    if case .inProgress(let percent) = state { progressView.progress = Float(percent) / 100 }
}
let notification = download.observe { state in
    if case .finished(let file) = state { notify("Downloaded \(file.lastPathComponent)") }
}
sealed interface DownloadState {
    data class InProgress(val percent: Int) : DownloadState
    data class Finished(val file: File) : DownloadState
    data class Failed(val message: String) : DownloadState
}

class Download {
    private val _state = MutableStateFlow<DownloadState>(DownloadState.InProgress(0))
    val state: StateFlow<DownloadState> = _state.asStateFlow()   // current value + updates

    fun report(percent: Int) { _state.value = DownloadState.InProgress(percent) }
    fun finish(file: File) { _state.value = DownloadState.Finished(file) }
    fun fail(message: String) { _state.value = DownloadState.Failed(message) }
}

// Progress bar, in a lifecycle-aware scope:
download.state.collect { state ->
    if (state is DownloadState.InProgress) progressBar.progress = state.percent
}
sealed interface DownloadState permits InProgress, Finished, Failed {}
record InProgress(int percent) implements DownloadState {}
record Finished(File file) implements DownloadState {}
record Failed(String message) implements DownloadState {}

class Download {
    private final ObservableValue<DownloadState> state = new ObservableValue<>(new InProgress(0));

    Runnable observe(Consumer<DownloadState> onChange) { return state.observe(onChange); }

    void report(int percent) { state.set(new InProgress(percent)); }
    void finish(File file) { state.set(new Finished(file)); }
    void fail(String message) { state.set(new Failed(message)); }
}

Runnable stopBar = download.observe(s -> {
    if (s instanceof InProgress p) progressBar.setProgress(p.percent());
});
type DownloadState =
  | { kind: "inProgress"; percent: number }
  | { kind: "finished"; file: string }
  | { kind: "failed"; message: string };

class Download {
  private readonly state = new ObservableValue<DownloadState>({ kind: "inProgress", percent: 0 });

  observe(onChange: (state: DownloadState) => void): () => void {
    return this.state.observe(onChange);
  }

  report(percent: number) { this.state.value = { kind: "inProgress", percent }; }
  finish(file: string) { this.state.value = { kind: "finished", file }; }
  fail(message: string) { this.state.value = { kind: "failed", message }; }
}

const stopBar = download.observe((state) => {
  if (state.kind === "inProgress") progressBar.value = state.percent;
});

Two patterns working together: Observer distributes changes, and a sealed state type makes sure every observer can see exactly one state, including the end states. The Kotlin version doesn’t need the hand-written observable at all: StateFlow already is one.

Exercisehard

Live prices for 100,000 app users

A trading app shows live prices. The backend receives up to 50,000 price updates per second for 5,000 instruments, and 100,000 app users are connected, each watching a watchlist of 10–50 instruments. On a slow mobile connection, a client can’t keep up with every tick.

Design the server side as an Observer at scale: how clients subscribe, how updates fan out, and what happens with slow clients, reconnects and server restarts.

›

A user doesn’t need every tick, only the latest price per instrument at a rate their screen and network can handle. That’s conflation, applied per client.

›

Fan-out has two levels: from the price feed to the server instances, and from each instance to its connected clients. Index subscriptions by instrument, not by client.

SolutionTry it yourself first
/** One per connected client: keeps only the newest price per instrument, flushed at most 4× per second. */
class ClientSession(private val socket: WebSocketSession) {
    private val pending = ConcurrentHashMap<String, Price>()      // conflation: latest wins
    private val wakeUp = Channel<Unit>(Channel.CONFLATED)

    fun offer(price: Price) {
        pending[price.instrument] = price
        wakeUp.trySend(Unit)                                       // never blocks the feed
    }

    suspend fun run() {
        for (signal in wakeUp) {
            val batch = pending.keys.mapNotNull { pending.remove(it) }
            if (batch.isNotEmpty()) socket.send(encode(batch))      // suspends on a slow client
            delay(250)                                              // per-client rate limit
        }
    }
}

/** Per server instance: instrument -> sessions watching it. */
class Fanout {
    private val watchers = ConcurrentHashMap<String, MutableSet<ClientSession>>()

    fun subscribe(session: ClientSession, instruments: Set<String>) =
        instruments.forEach { watchers.computeIfAbsent(it) { ConcurrentHashMap.newKeySet() }.add(session) }

    fun unsubscribe(session: ClientSession) = watchers.values.forEach { it.remove(session) }

    fun onPrice(price: Price) = watchers[price.instrument]?.forEach { it.offer(price) }
}

The architecture:

LayerChoiceWhy
Feed → serversPrice updates on a pub/sub topic (Kafka, Redis Streams, NATS), every instance consumes all instruments or a shard of them50k msgs/s is cheap to broadcast to tens of instances; sharding by instrument when it isn’t
Servers → clientsWebSocket (or SSE) connections, ~10k per instance, behind a load balancer with long-lived connectionsServer push without polling
Subscription indexinstrument → sessions per instanceA tick touches only the clients that watch it: 50k ticks/s × ~20 watchers each, not × 100k clients
Slow clientsPer-client conflation (latest price per instrument) and a flush rate limit; disconnect clients whose socket stays blockedA slow phone gets fewer, fresher updates; it never slows the feed or other clients
ReconnectClient resubscribes with its watchlist; the server immediately sends a snapshot of current prices, then deltasIt’s state, not events: a late subscriber needs the current value (like StateFlow)
Server restartClients reconnect with jittered backoff to other instances; snapshots come from a last-price cache (Redis)No thundering herd, no gap in displayed prices

The Observer-specific decisions to call out: the feed never waits for clients (trySend into a conflated buffer), notification is decoupled from delivery (offer vs a per-client coroutine), subscriptions have an owner and an end (socket close → unsubscribe, with idle timeouts for half-open connections), and observers get state with a current value, so missed ticks don’t matter.

  • Subscription lifetime is the main source of leaks and ghost updates: a long-lived subject keeps every listener, and every listener keeps its screen.
  • Prefer lifecycle-aware APIs over hand-rolled listeners; at scale, decouple notification from delivery (per-subscriber conflation, a feed that never waits).

Swift

SwiftUI views observe @Observable models only while on screen; for hand-rolled tokens, store them in a property so they die with the owner.

Kotlin

collectAsStateWithLifecycle() and repeatOnLifecycle stop collecting when the UI goes away. Removing a listener during iteration of a plain list throws ConcurrentModificationException: notify over a copy or a CopyOnWriteArrayList.

Java

Unsubscribe in onDestroyView (or the matching lifecycle callback). Removing a listener during iteration of an ArrayList throws ConcurrentModificationException: iterate a copy or use CopyOnWriteArrayList.

TypeScript

Return the unsubscribe function from useEffect, and React calls it on unmount (Strict Mode mounts twice in development to expose missing cleanups). Set iteration tolerates deleting the current element; splice in an array loop skips the next one.

Check this part

  1. A screen subscribes to the app-wide cart every time it opens and never unsubscribes. The user opens it ten times. What does the cart hold?

  2. How do you tie the subscription to the screen's lifetime?

  3. Observers are stored in a dictionary and notified with observers.values.forEach. One removes itself during its call. What happens?Listeners are in a mutableListOf() and notified with a for loop. One removes itself during its call. What happens?Listeners are in an ArrayList and notified with for-each. One removes itself during its call. What happens?Listeners are in a Set and notified with for…of. One deletes itself during its call. What happens?

Every subscription needs an end: keep the token or unsubscribe function, and cancel it when the screen goes away.

Part Not started

Late subscribers: state or events?

What should a listener receive if it subscribes after something changed? It depends on what is being observed:

  • State is “what things are like now”: the number of items in the cart, whether the user is signed in. A screen that starts listening late still needs the current value, right away.
  • Events are “something happened”: a payment succeeded, show a toast, go to the receipt. Each one should be handled once; replaying it to a late listener would show the toast again.

Each platform has a tool for each: @Published / @Observable and StateFlow hold state, while NotificationCenter, PassthroughSubject, Kotlin Channels, Java PropertyChangeSupport and Node’s EventEmitter only report events as they happen.

A simple rule: observe state (the cart, the user, the screen’s data) with something that has a current value, and events (a toast, “navigate to the receipt”) with something that delivers each one once.

Predict the output

The cart count changes to 2, and only then a new screen starts observing. What does it print right away?

final class CartModel: ObservableObject {
    @Published var count = 0
}

let cart = CartModel()
cart.count = 2

let subscription = cart.$count.sink { print($0) }
val count = MutableStateFlow(0)
count.value = 2

scope.launch {
    count.collect { println(it) }
}
class CartModel {
    final PropertyChangeSupport changes = new PropertyChangeSupport(this);
    private int count;
    void setCount(int newCount) {
        int old = count;
        count = newCount;
        changes.firePropertyChange("count", old, newCount);
    }
}

CartModel cart = new CartModel();
cart.setCount(2);
cart.changes.addPropertyChangeListener(e -> System.out.println(e.getNewValue()));
const cart = new EventEmitter();             // node:events
cart.emit("count", 2);

cart.on("count", (count) => console.log(count));

State vs events in practice

The most common Observer bug in modern apps is using the wrong one:

  • Events as state: navigateToReceipt = true in a StateFlow/@Published/BehaviorSubject is redelivered to every new collector, so after rotation (or re-subscription) the app navigates again. Either consume and reset it explicitly, or model it as state the UI renders (checkoutComplete(orderId)), which survives process death too.
  • State as events: a SharedFlow(replay = 0) drops values when nobody collects, so a screen that subscribes a moment too late shows stale data forever.
  • Conflation surprises: StateFlow skips values equal to the current one and may skip intermediate values for slow collectors. That’s correct for state and wrong for “the user tapped twice”.

Equal values

Predict the output

How many times does the observer print?

final class Store: ObservableObject {
    @Published var items = [1]
}

let store = Store()
let subscription = store.$items.sink { print($0) }
store.items = [1]
val items = MutableStateFlow(listOf(1))

scope.launch { items.collect { println(it) } }
items.value = listOf(1)
PropertyChangeSupport changes = new PropertyChangeSupport(this);
changes.addPropertyChangeListener("items", e -> System.out.println(e.getNewValue()));

changes.firePropertyChange("items", List.of(1), List.of(1));
const items = new BehaviorSubject([1]);      // rxjs

const subscription = items.subscribe((value) => console.log(value));
items.next([1]);

Deep dives

Deep divePush or pull?Interview favorite

A push observer receives the new data with the notification (onChange(items)). A pull observer only hears “something changed” and reads what it needs from the subject. Push is simpler for observers and avoids a race where the subject changes again before they read; pull lets each observer read only what it needs and keeps notifications cheap.

Modern state holders are mostly push with a current value (StateFlow, @Published, BehaviorSubject), while SwiftUI’s Observation is a pull model underneath: it records which properties a view read and only says “redraw”, and the view reads the fresh values itself.

Deep diveObserver vs publish/subscribeInterview favorite

In the Observer pattern, observers register directly with the subject, in the same process, and are usually notified synchronously. In publish/subscribe, publishers and subscribers don’t know each other: they share a topic on a broker or event bus (Kafka, Redis pub/sub, an in-app event bus), and delivery is often asynchronous.

Pub/sub decouples further (in time, place and number of parties), at the cost of being harder to trace: “who reacts to this event?” is no longer answered by the code that publishes it. Interviewers ask for the difference, and a good answer names that trade-off.

SwiftKotlinJavaTypeScript
Observable state@Observable, @PublishedStateFlow (conflated, dedups with equals)PropertyChangeSupport + your own current value, ReactorBehaviorSubject, signals, a store with useSyncExternalStore
One-off eventsAsyncStream, PassthroughSubjectChannel + receiveAsFlow(), SharedFlow(replay = 0)Spring events, Flow.PublisherEventEmitter, RxJS Subject
  • Events as state (navigateToReceipt = true in a state holder) are redelivered to every new subscriber: the app navigates again after rotation. State as events drops values nobody was listening for.

Swift

@Published emits every assignment, equal or not; add .removeDuplicates().

Kotlin

StateFlow skips values equals to the current one, including a mutated list that is the same instance: in-place changes are never seen.

Java

firePropertyChange fires nothing when old and new values are equal and non-null, and replays nothing to late listeners.

TypeScript

BehaviorSubject.next emits every value; distinctUntilChanged() compares by ===, so a new array with the same items still counts as a change.

Check this part

  1. The cart count (@Published) is already 2 when a new screen subscribes with sink. What does the screen get right away?The cart count (StateFlow) is already 2 when a new screen starts collecting. What does it get right away?The count already changed to 2 when a new screen adds a PropertyChangeListener. What does it get right away?A count event with 2 was already emitted on an EventEmitter when a new screen calls on("count", …). What does it get right away?

  2. A badge shows the number of items in the cart. What should a screen get when it starts listening?

  3. @Published var items = [1]; you sink, then assign [1] again. How many prints?MutableStateFlow(listOf(1)); you collect, then set listOf(1) again. How many prints?You add a listener, then firePropertyChange("items", List.of(1), List.of(1)). How many prints?new BehaviorSubject([1]); you subscribe, then call next([1]). How many prints?

  4. A ViewModel sets navigateToReceipt = true in a state holder (StateFlow, @Published, BehaviorSubject). The screen is recreated. What happens?

Observe state with something that has a current value; deliver one-off events once. Mixing them up re-runs navigation or loses data.

Key takeaways

  • Observer = a subject keeps a list of observers and notifies them when it changes, without knowing who they are.
  • Every subscription needs an end: store the token or handle and unsubscribe when the screen goes away.
  • Observe state with something that has a current value (StateFlow, @Published, @Observable, BehaviorSubject); use events for one-off things.
  • Notify with a snapshot of the data and of the listener list, so listeners can’t break the subject or the loop.
  • Choose the observable by semantics: state (current value, conflated, deduplicated) vs events (each delivered once, maybe buffered).
  • Subscription lifetime is the main source of leaks and ghost updates; prefer lifecycle-aware APIs over hand-rolled listeners.
  • Notify outside locks, after the mutation is complete, on a documented thread; isolate slow or failing observers.
  • At scale, decouple notification from delivery: per-subscriber conflation, snapshots on (re)subscribe, and a feed that never waits.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.