The Observer Pattern: Send a Snapshot, Not a Live Reference

By Sergey Nosov

16 October 2026

Suppose an order book tells an audit log about every new order, and the log writes its entries in batches. Two orders arrive, the log flushes, and both entries describe the second order. Nothing throws, and the subject follows the Observer pattern as the textbook describes it. The subject notifies its observer each time, but it hands over a reference to itself, and the observer reads that reference after the book has changed again.

The classic treatment of Observer frames the payload as push versus pull: how much information travels with a notification. I think the more important question is what kind of payload the subject hands out. A snapshot of what happened stays true for as long as the observer keeps it. A reference to mutable state shows whatever the state is when the observer finally looks. This article builds the pattern in C# and TypeScript, reproduces the audit-log bug, and fixes it.

Then it covers the other decisions hidden in every subject. What happens when the list of observers changes during a notification, or when an observer throws? Who ends a subscription? What if an observer triggers another notification, or several threads share one subject? The C# samples run on .NET 10, and the TypeScript samples run on Node.js 24 and pass TypeScript 7.0’s strict checks.

The Pattern in Two Roles

Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, the Gang of Four, cataloged Observer in Design Patterns: Elements of Reusable Object-Oriented Software (1994). Its intent reads: “Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.” The book gives the pattern two other names, Dependents and Publish-Subscribe. It calls Smalltalk’s Model/View/Controller “the first and perhaps best-known example”: the views observe the model.

The subject holds the state worth watching, keeps a list of observers, and lets them attach and detach. When its state changes, it notifies every observer on the list. In the book’s words, it “sends out these notifications without having to know who its observers are.” An observer implements an update method and knows nothing about the other observers.

In C#, I call the observer interface ISubscriber<T>, because System already has an IObserver<T> with a different contract. A library that reuses that name gives its users compiler error CS0104, an ambiguous reference, in any file that names the interface and imports both namespaces. Since .NET 6, new C# projects import System implicitly in every file. Here is a minimal version:

public interface ISubscriber<in T>
{
    void Update(T value);
}

public class Subject<T>
{
    private readonly List<ISubscriber<T>> _subscribers = [];

    public void Attach(ISubscriber<T> subscriber) =>
        _subscribers.Add(subscriber);

    public void Detach(ISubscriber<T> subscriber) =>
        _subscribers.Remove(subscriber);

    protected void Notify(T value)
    {
        foreach (var subscriber in _subscribers.ToArray())
            subscriber.Update(value);
    }
}

The loop in Notify walks a copy of the list, made by ToArray(), because an observer may detach itself during its own update. The TypeScript version has the same shape:

interface Subscriber<T> {
  update(value: T): void;
}

class Subject<T> {
  private subscribers: Subscriber<T>[] = [];

  attach(subscriber: Subscriber<T>): void {
    this.subscribers.push(subscriber);
  }

  detach(subscriber: Subscriber<T>): void {
    this.subscribers = this.subscribers.filter((s) => s !== subscriber);
  }

  protected notify(value: T): void {
    for (const subscriber of [...this.subscribers]) {
      subscriber.update(value);
    }
  }
}

If you have called addEventListener in a browser, you have used this shape: the element is the subject, and your listener is an observer. The pattern’s main benefit follows from the two roles. A new observer needs no change to the subject or to the other observers. The Observer entry in my Software Development Principles series lists that as the first sign that the pattern is applied well.

Snapshot or Live Reference?

The Gang of Four describe two extremes for the payload. “At one extreme, which we call the push model, the subject sends observers detailed information about the change, whether they want it or not. At the other extreme is the pull model; the subject sends nothing but the most minimal notification, and observers ask for details explicitly thereafter.” They weigh the two by reuse and cost. The push model “assumes subjects know something about their observers’ needs,” and the pull model makes each observer work out what changed.

For an observer that watches several subjects, the authors note, a subject can pass itself to Update, “thereby letting the observer know which subject to examine.” Their design can afford that live reference, for a reason that is easy to miss. Its observers “query the subject for its current state in the course of updating their own state”: during the call, after the subject has finished its change. Trouble starts when the read moves out of the call.

The audit log from the opening, built on the minimal subject, keeps each notification and writes the batch later:

public sealed record Order(int Id, decimal Total);

public sealed class OrderBook : Subject<OrderBook>
{
    private readonly List<Order> _orders = [];

    public int Count => _orders.Count;
    public Order Latest => _orders[^1];

    public void Add(Order order)
    {
        _orders.Add(order);
        Notify(this);
    }
}

// Writes entries in batches
public sealed class AuditLog : ISubscriber<OrderBook>
{
    private readonly List<OrderBook> _pending = [];

    public void Update(OrderBook book) => _pending.Add(book);

    public void Flush()
    {
        foreach (var book in _pending)
            Console.WriteLine(
                $"Order {book.Latest.Id} added; book holds {book.Count}");
        _pending.Clear();
    }
}

Two orders, then a flush:

var book = new OrderBook();
var log = new AuditLog();
book.Attach(log);

book.Add(new Order(1, 25.00m));
book.Add(new Order(2, 40.00m));
log.Flush();
// Prints:
// Order 2 added; book holds 2
// Order 2 added; book holds 2

Both entries describe the second order, because the log stored the book twice and read it only after the second change. The observer made the mistake, but the subject invited it: nothing in Update(OrderBook book) says that the book must be read before the call returns.

In real code the read moves later for ordinary reasons: an observer queues work, hands it to another thread, or awaits something first. On another thread, the read can even race with the next change: the List<T> reference notes that the default collections in System.Collections.Generic “are not synchronized.”

The fix is to send what happened instead of where to look. The subject builds a small immutable record with the facts the event stands for and notifies with that:

public sealed record OrderAdded(
    int OrderId, decimal Total, int BookCount, DateTimeOffset At);

public sealed class OrderBook : Subject<OrderAdded>
{
    private readonly List<Order> _orders = [];

    public void Add(Order order)
    {
        _orders.Add(order);
        Notify(new OrderAdded(
            order.Id, order.Total, _orders.Count, DateTimeOffset.UtcNow));
    }
}

public sealed class AuditLog : ISubscriber<OrderAdded>
{
    private readonly List<OrderAdded> _pending = [];

    public void Update(OrderAdded e) => _pending.Add(e);

    public void Flush()
    {
        foreach (var e in _pending)
            Console.WriteLine(
                $"Order {e.OrderId} added; book holds {e.BookCount}");
        _pending.Clear();
    }
}

The same two orders and flush now print:

Order 1 added; book holds 1
Order 2 added; book holds 2

Now the payload is a fact about the past. The log can keep it, queue it, replay it, or send it to another thread or another machine, and its meaning does not drift. My rule is short: never hand observers a live reference to mutable subject state if you can hand them a snapshot instead.

Microsoft’s .NET guidance advises against passing the provider itself, for reasons of its own. Its best-practice page, refreshed in June 2026, explains that a dedicated type “keeps the provider’s responsibilities separate from the notification payload and makes the API easier to evolve.” The sample in its observer article passes a readonly record struct.

The usual tools for immutability are shallow. The C# language reference says that a record’s init-only properties have “shallow immutability”: an array inside the record can still change. In TypeScript, readonly is a compile-time check that, in the handbook’s words, “won’t change any behavior at runtime,” and Object.freeze freezes only the top level. So build snapshots from immutable types and collections. A mutable copy is not enough, because every observer shares it.

A snapshot answers what happened, but a screen that redraws from current state needs only what is true now. Redux works that way: its listeners receive no payload and call getState(). The documentation says that a listener “should not expect to see all state changes” but is guaranteed to see the latest state. Because Redux state is not supposed to change in place, a listener can even keep the state it reads. The project’s style guide calls mutating state “the most common cause of bugs in Redux applications.”

So the choice has two safe forms. If an observer needs to know what happened, push an immutable snapshot. If it needs to know what is true now, send a bare signal. Let it read from state that cannot change under it, or during the call if no other thread can change the subject.

When the List Changes during a Notification

If an observer detaches itself or attaches another observer during a notification, it changes the list that the subject is walking. In C#, the list’s enumerator is then “irrecoverably invalidated,” as its documentation puts it, and the next step throws an InvalidOperationException.

A JavaScript array iterator throws nothing, so the failure is silent. If an observer removes itself with splice, the later observers shift left and the loop skips the next one. If it adds an observer with push, the newcomer runs in the same pass. The detach in the minimal TypeScript subject would be safe even without the copy, because filter builds a new array, but attach changes the array in place.

Copying the list before the loop fixes both problems, with one consequence. An observer removed by an earlier observer in the same round still receives the current notification, because it is in the copy.

Most .NET events behave the same way, and so does Node.js’s EventEmitter. A typical .NET event keeps its handlers in a delegate. Delegates are immutable, so an event raised with ?.Invoke calls the handler list as it stood when the call began. The EventEmitter documentation says that removing listeners during an emit “will not remove them from emit() in progress.”

The DOM chose differently. The DOM Standard clones a target’s listener list each time its listeners are about to be called, which “avoids event listeners added after this point from being run.” A listener added to an ancestor still runs as the event bubbles up. The standard adds that “removal still has an effect”: a removed listener is flagged and skipped. Chrome follows the standard. Node.js 24’s EventTarget skips removed listeners too, but a listener added during a dispatch can run in that dispatch.

Skipping a removed listener suits an observer that detaches because it is being disposed. Either behavior is defensible if you choose it on purpose and write it down. The same goes for duplicates: the DOM ignores a second identical registration, while .NET events and EventEmitter call the listener twice.

When an Observer Throws

In the minimal subject, an exception from one observer ends the loop, so the observers after it never hear about the change. The exception also escapes from the method that changed the subject. In the order book, Add would throw after the order was already in the book, and a caller that retries on failure would add it twice.

Platforms disagree here too. The C# programming guide says of a multicast delegate that when one of its methods throws, “no subsequent methods in the invocation list are called.” EventEmitter.emit() also stops at the first listener that throws. The DOM reports the exception and moves on to the next listener. Node.js’s EventTarget does the same, then treats the error as an uncaught exception, which by default ends the process. For IObserver<T>, Microsoft puts the burden on observers: “Don’t throw exceptions from interface implementations such as OnNext or OnError.”

When observers are independent, one failure should not decide what the others see. The subject can isolate them: call every observer, collect the failures, and report them together afterward. The final versions below do that, with an AggregateException in C# and an AggregateError in TypeScript. Neither version can catch a failure in an async observer: it surfaces later, outside the loop.

Whether the subject stops at a failure or isolates it, its state has already changed. The exception means that a notification failed, not the change, so a caller should not retry the change.

Who Ends the Subscription?

A subject holds a reference to every observer it notifies. If nobody detaches an observer that is no longer needed, the subject keeps it alive. The observer still reacts on behalf of a screen, a request, or a connection that no longer exists. My Software Development Principles entry calls this the lapsed listener problem. The leaky abstractions article on this site describes its .NET form: a long-lived publisher keeps every forgotten subscriber in memory.

The fix is structural: make detaching as easy as attaching, and tie it to the observer’s lifetime. In .NET, IObservable<T>.Subscribe returns an IDisposable, and Redux’s subscribe returns a function that unsubscribes. The final subjects in this article do the same. An observer can then end its subscription from its own Dispose method in C#, or from a UI component’s cleanup step. In browsers and in Node.js, an AbortSignal passed to addEventListener lets one abort() call remove every listener registered with it. For example, in a browser:

const controller = new AbortController();
const { signal } = controller;
window.addEventListener("resize", onResize, { signal });
document.addEventListener("keydown", onKey, { signal });

// When the view goes away
controller.abort();

Where two lifetimes cannot be tied together, weak references are the fallback. WPF’s weak event pattern lets a listener be collected even if it never detaches. Until it is collected, though, the listener keeps reacting. A listener that nothing else holds can even be collected while it is still needed. Node.js also offers a detector: an EventEmitter prints a warning when more than ten listeners are added for one event. Its documentation calls that “a useful default that helps finding memory leaks.”

When an Observer Triggers Another Notification

An observer’s reaction can change the subject, and the subject then notifies again, inside the first notification. Suppose an inventory restocks automatically when stock runs low, and a second observer logs every change. The demo runs on the minimal TypeScript subject:

type StockChanged = Readonly<{
  sku: string;
  change: number;
  onHand: number;
}>;

class Inventory extends Subject<StockChanged> {
  private stock = new Map<string, number>();

  adjust(sku: string, change: number): void {
    const onHand = (this.stock.get(sku) ?? 0) + change;
    this.stock.set(sku, onHand);
    this.notify(Object.freeze({ sku, change, onHand }));
  }
}

const inventory = new Inventory();
const log: string[] = [];

// Restocks when stock runs low
inventory.attach({
  update(e) {
    if (e.onHand < 5) inventory.adjust(e.sku, 50);
  },
});
// Records every change
inventory.attach({
  update(e) {
    log.push(`change ${e.change}, on hand ${e.onHand}`);
  },
});

inventory.adjust("A-100", 13);
inventory.adjust("A-100", -10);
console.log(log.join("\n"));

It prints:

change 13, on hand 13
change 50, on hand 53
change -10, on hand 3

The last line says three units are on hand, but the inventory holds fifty-three. The sale’s notification reached the restocking observer first, and its reaction ran a complete nested notification before the outer loop moved on. So the log heard about the restock before the sale that caused it. Snapshots do not help here: every payload is correct, but the order is wrong.

The fix is to finish one round before starting the next. If a notification arrives while the subject is already notifying, the subject queues it, and the outer loop delivers the queue in order.

The final TypeScript subject also skips an observer removed earlier in the round, as the DOM does, and returns a function that detaches. The subject isolates failures and reports them from the outer call once the queue is empty. Its list never changes in place: attach and the detach function build a new array, so a loop already in progress keeps walking the old one.

interface Subscriber<T> {
  update(value: T): void;
}

type Entry<T> = {
  subscriber: Subscriber<T>;
  active: boolean;
};

class Subject<T> {
  private entries: readonly Entry<T>[] = [];
  private queue: T[] = [];
  private notifying = false;

  // Returns a detach function
  attach(subscriber: Subscriber<T>): () => void {
    const entry = { subscriber, active: true };
    this.entries = [...this.entries, entry];
    return () => {
      entry.active = false;
      this.entries = this.entries.filter((e) => e !== entry);
    };
  }

  protected notify(value: T): void {
    this.queue.push(value);
    if (this.notifying) return;
    this.notifying = true;
    const errors: unknown[] = [];
    try {
      while (this.queue.length > 0) {
        const batch = this.queue;
        this.queue = [];
        for (const item of batch) {
          this.deliver(item, errors);
        }
      }
    } finally {
      this.queue = [];
      this.notifying = false;
    }
    if (errors.length > 0) {
      throw new AggregateError(errors, "A subscriber failed");
    }
  }

  private deliver(value: T, errors: unknown[]): void {
    for (const entry of this.entries) {
      if (!entry.active) continue;
      try {
        entry.subscriber.update(value);
      } catch (error) {
        errors.push(error);
      }
    }
  }
}

With this subject, the same program prints the sale before the restock:

change 13, on hand 13
change -10, on hand 3
change 50, on hand 53

A queue has a cost: when the restocking observer’s call to adjust returns, the other observers have not yet heard about the restock. Redux handles nested dispatches differently: a listener may miss intermediate states, which a screen can afford. An audit log needs every event in order, so it needs the queue. A queue does not break a cycle, though; it turns a stack overflow into an endless loop.

Many Threads, One Subject

Everything so far ran on one thread. In C#, observers can attach and detach on any thread while another thread notifies. Microsoft’s best-practice page asks for thread-safe subscribe and dispose methods: “Typically, this involves using a concurrent collection or a lock.”

Microsoft’s own sample takes the lock only to change or copy its collections, and it calls observers after releasing the lock. A lock held while observers run lets one slow observer stall every thread that touches the subject. Worse, if an observer waits for a thread that needs the same lock, both threads deadlock. Here is the final C# subject:

public class Subject<T>
{
    private readonly Lock _lock = new();
    private readonly List<Subscription> _subscriptions = [];

    public IDisposable Attach(ISubscriber<T> subscriber)
    {
        var subscription = new Subscription(this, subscriber);
        lock (_lock) _subscriptions.Add(subscription);
        return subscription;
    }

    protected void Notify(T value)
    {
        Subscription[] copy;
        lock (_lock) copy = [.. _subscriptions];

        List<Exception>? errors = null;
        foreach (var subscription in copy)
        {
            if (!subscription.IsActive) continue;
            try
            {
                subscription.Subscriber.Update(value);
            }
            catch (Exception ex)
            {
                (errors ??= []).Add(ex);
            }
        }

        if (errors is not null)
            throw new AggregateException(errors);
    }

    private sealed class Subscription(
        Subject<T> owner, ISubscriber<T> subscriber) : IDisposable
    {
        private volatile bool _active = true;

        public ISubscriber<T> Subscriber => subscriber;
        public bool IsActive => _active;

        public void Dispose()
        {
            _active = false;
            lock (owner._lock) owner._subscriptions.Remove(this);
        }
    }
}

Dispose clears a flag before it removes the subscription, and Notify checks the flag, so an observer removed during a round is skipped. Across threads, the flag narrows the race but does not close it. If one thread has already passed the check when another thread disposes the subscription, the observer runs once more.

Eric Lippert described the same race for .NET events in a 2009 post. His conclusion: “event handlers are required to be robust in the face of being called even after the event has been unsubscribed.” An observer that releases resources when it detaches should check that they are still there.

Unlike the TypeScript version, the final C# subject does not queue nested notifications. Two threads that notify at once can call the same observer at once, so observers must be thread-safe. The subject’s lock guards only its list of observers; the state that a derived class adds needs locking too. When every observer must see the changes in order, change the state and enqueue the notification under one lock, and deliver from a single loop. In that design, Notify can return before the observers run.

You Have Probably Used It Already

Usually, you use a subject that someone else built. The reason to learn the pattern is to recognize it, name it, and read other people’s code faster. Here are common examples:

Each of these has already made the decisions in this article. Look up which way each one went before you rely on it.

When to Use It, and When Not To

Reach for Observer when one change has to fan out to several independent reactions. It also fits when the set of reactions is not known until run time, or when the subject must not depend on the code that reacts to it. The Gang of Four describe the last case as an object that “should be able to notify other objects without making assumptions about who these objects are.”

Avoid it in three cases:

Observers also hide control flow, because the code that changes the subject does not show what runs next. The pattern is a tool, not a goal. The goal is always the clearest expression of the actual relationship between the objects.

A Checklist for Every Subject

Further Reading