# A subscription is a state machine

By ayobamih

Canonical: https://aiworkaccountability.com/writing/a-subscription-is-a-state-machine/
Published: 2026-10-25
Updated: 2026-10-25

**Ayobami John Haastrup · Technical case study · Evidence reviewed 25 October 2026**

A caller asks which surveys match the current user. The answer is empty. A qualifying event happens a second later, but the caller never hears about it. That was the gap described in [PostHog issue #1498](https://github.com/PostHog/posthog-js/issues/1498).

The existing getter was a one-shot query. Turning it into a stream of updates would have changed its contract for existing consumers. My contribution, [PR #4794](https://github.com/PostHog/posthog-js/pull/4794), added a separate `onActiveMatchingSurveysChanged` subscription. It merged on 29 September 2026.

The public method is small. Its difficult part is deciding which result is still valid when an asynchronous request finishes, a callback changes state, or the survey definitions change underneath it. This article examines those decisions in the merged code and the review that refined them.

## Start with the observable contract

A subscription needs an initial snapshot, subsequent updates and a way to stop receiving them. Here it also needs to explain whether an empty array means that no surveys match or that definitions could not be loaded.

The added method preserves the getter's one-shot behaviour and returns an unsubscribe function. Its documented updates include activation, consumption, session changes, refreshed definitions, captured pageviews, feature-flag changes, marking surveys as seen and reset. It does **not** promise continuous observation of arbitrary DOM mutations or the passage of time. URL changes enter through the pageview pipeline, including automatic SPA pageviews when configured.

That boundary is part of correctness. Without it, a caller can interpret a stale result as a definitive answer about current eligibility. The [merged public API contract](https://github.com/PostHog/posthog-js/blob/e89d224c07b296e6a16ff9bdb0d9bce5876fc202/packages/browser/src/posthog-core.ts) makes the supported update sources explicit.

## Removing a callback does not revoke queued work

The first important review finding concerned unsubscribe during the initial fetch:

1. A consumer subscribes while the definitions cache is empty.
2. An asynchronous request retains a delivery callback.
3. The consumer unsubscribes, removing its registration from the registry.
4. The request resolves and invokes the callback it already retained.

Looking at the registry alone cannot prevent step four. The callback already exists elsewhere. In a React integration, that can mean an effect's cleanup has run but a late delivery still attempts to update the component. The reviewer supplied this concrete sequence in the [unsubscribe review thread](https://github.com/PostHog/posthog-js/pull/4794#discussion_r3962322581).

The correction gives each registration its own identity and `active` state, and checks that state **inside delivery**. Registering the same function twice creates two subscriptions, so stopping one does not silently stop the other. Disposal invalidates all registrations. The [pending-response regression](https://github.com/PostHog/posthog-js/blob/e89d224c07b296e6a16ff9bdb0d9bce5876fc202/packages/browser/src/__tests__/active-matching-surveys.test.ts#L156-L167) deliberately removes the fixture's default cache before testing the race; otherwise the test would bypass the asynchronous path it was supposed to exercise.

## Active state is necessary, but insufficient

A registration can still be active when its answer becomes obsolete. An earlier evaluation may finish after a newer one. A consumer callback may also synchronously capture a cancelling event while the notification loop is still visiting subscribers.

The implementation assigns an increasing evaluation revision to each registration. Delivery checks that the revision it captured is still current. It records the last delivered representation before invoking consumer code, and isolates exceptions around delivery. The loop iterates a copy of the registration list, while the active-state check still honours an unsubscribe that happens during another callback.

This is the central pattern, shown as explanatory pseudocode rather than a copy of the SDK:

```js
const evaluation = ++registration.revision

evaluateMatchingSurveys((snapshot) => {
  if (disposed || !registration.active) return
  if (evaluation !== registration.revision) return

  const identity = serialise(snapshot, loadState)
  if (identity === registration.lastDelivered) return

  registration.lastDelivered = identity
  deliverToConsumer(snapshot, loadState)
})
```

The [merged implementation](https://github.com/PostHog/posthog-js/blob/e89d224c07b296e6a16ff9bdb0d9bce5876fc202/packages/browser/src/posthog-surveys.ts#L517-L602) supplies the actual error handling and load-context rules. This is a local delivery fence, not a general distributed-consistency guarantee. The tests separately cover repeated callback registration, re-entrant unsubscribe, cancellation during notification and disposal with a pending fetch.

## Deduplicate the answer, after evaluating it

Another review finding showed why activation IDs were the wrong place to suppress repeated notifications. Imagine a survey restricted to `/checkout`. Its activation event fires on `/`, so it does not match. The user navigates to `/checkout` and triggers it again. The activation ID can be unchanged while eligibility has changed.

In compact form:

```text
matching = evaluate(activation, URL, flags, definitions, lifecycle state)
```

Equality of one input cannot establish equality of that output. The [review reproduction](https://github.com/PostHog/posthog-js/pull/4794#discussion_r3962322589) made this failure concrete. The correction evaluates matching first, then compares the serialised matching definitions and load context with the registration's previous delivery. A changed definition with the same survey ID can therefore produce an update.

This comparison uses the representation of the result, including array order. It is not a mathematical set-equivalence algorithm or a demonstrated optimal comparison strategy. Its practical role is to suppress unchanged deliveries without using activation IDs as a shortcut for all matching state.

## A successful refresh can remove things

Refreshing definitions introduces a different problem. A response may remove a survey, change its action trigger, or contain no surveys at all. If the receiver accumulates registrations, a deleted trigger can remain active even after the cache has been refreshed.

The contribution's follow-up treats definitions as a **complete snapshot** and replaces the receiver registrations. The [refresh regression](https://github.com/PostHog/posthog-js/blob/e89d224c07b296e6a16ff9bdb0d9bce5876fc202/packages/browser/src/__tests__/active-matching-surveys-refresh.test.ts#L176-L203) changes an action from `account_upgraded` to `purchase_completed`, verifies the old trigger stops working, then applies an empty refresh and verifies the replacement trigger is removed too.

That test is stronger than checking that the new trigger works. It tests subtraction. The right operation depends on the upstream contract: replacement is appropriate for a complete snapshot; applying it to an incremental feed would lose valid state. This case does not justify replacing every registry on every update.

Notification ordering matters too. The cache and receiver definitions must be current before subscribers evaluate again. A refresh can change matching even when no activation event occurs.

## Lifecycle hooks must observe the state after it changes

Review also exposed an action-only fixture gap. A combined event/action fixture installed a lifecycle hook through its event conditions, concealing the fact that action-only surveys could activate without notifying subscribers when consumed. The correction ensures that action conditions install the necessary lifecycle handling.

A later finding involved surveys with no activation targeting at all. Dismissal and submission changed their seen state, but an activation-driven notification path did not necessarily run. The correction belongs at the shared matching-condition layer. In the merged browser adapter, capture hooks observe lifecycle events after core capture has applied the seen-state update. The [helper-level regressions](https://github.com/PostHog/posthog-js/blob/e89d224c07b296e6a16ff9bdb0d9bce5876fc202/packages/browser/src/__tests__/active-matching-surveys-lifecycle.test.ts#L232-L256) compare the subscription with the one-shot getter after dismissal and submission.

The useful question is therefore more precise than “did an event fire?” It is “which state was visible when the observer evaluated?” Adding a listener at an earlier layer could notify promptly and still publish an obsolete answer.

## Empty, unavailable and failed are different observations

The callback context distinguishes a loaded empty result from a recoverable load error. A failure does not automatically end the subscription. The merged implementation also gives late subscribers the current availability state instead of replaying an error from before they registered; usable cached definitions can still be evaluated.

This distinction is relevant beyond surveys. An AI-work dashboard that displays “no outstanding tasks” when it failed to load tasks is making a stronger claim than its evidence supports. Here, the extra context makes the narrower claim expressible. The [late-subscriber tests](https://github.com/PostHog/posthog-js/blob/e89d224c07b296e6a16ff9bdb0d9bce5876fc202/packages/browser/src/__tests__/active-matching-surveys.test.ts#L337-L407) cover several availability states rather than treating all empty arrays alike.

## Review and credit are part of the technical record

The merged change was collaborative. My two signed contribution commits added the subscription work and refreshed-trigger replacement. Lucas Heriques supplied detailed lifecycle and API-contract findings and approved the revised implementation. Subsequent maintainer work by Dustin Byrne updated the branch and added corrections for progress ordering, selector ownership across action matchers, partial responses, late-subscriber availability and cooldown re-evaluation. Those [commits remain visible in the PR](https://github.com/PostHog/posthog-js/pull/4794/commits).

I do not attribute those maintainer corrections to myself. They extend the lesson: the new observer made existing state and ordering assumptions observable, including assumptions shared with other consumers. The final result is more substantial than the original method addition, and the review history explains why.

## What the evidence establishes

| Boundary | Inspectable regression or record |
| --- | --- |
| Unsubscribe before initial response | Delayed-response test asserts no callback after cleanup |
| Same function registered twice | Each registration has independent lifetime |
| Re-entrant consumer code | Tests for cancellation and unsubscribe during notification |
| Changed eligibility with unchanged activation | URL, flag and changed-definition cases |
| Complete definitions refresh | Old trigger removed, new trigger active, empty refresh removes it |
| Compiled public API | Browser tests cover navigation, consumption and delayed unsubscribe |

The [compiled API tests](https://github.com/PostHog/posthog-js/blob/e89d224c07b296e6a16ff9bdb0d9bce5876fc202/packages/browser/playwright/mocked/surveys/active-matching-subscription.spec.ts) use a controlled response gate for delayed unsubscribe. They wait for request callbacks to drain rather than inferring completion from an arbitrary sleep.

On 25 October, GitHub returned **67 check runs for final PR head `97e7b251e532ed31b8e144410c00e4df3c1a5272`: 63 successful and four skipped**. The retrieved results include successful unit, functional, Chromium, Firefox and Safari jobs. They are CI observations, not a fresh test execution by this article's author. The PR's larger historical test totals describe its earlier verification reports and are not substituted for this final-head snapshot. The [curated evidence record](/evidence/open-source/posthog-4794.json) preserves the references and distinction.

This single contribution does not measure production engagement, prove the absence of every lifecycle defect, or establish a new algorithm. It does provide an inspectable example of a broader engineering obligation: when an API promises continuing knowledge of changing state, its implementation must define when that knowledge becomes stale and when delivery is still permitted.


By Ayobami John Haastrup (ayobamih). The contribution and this retrospective used ChatGPT/Codex assistance. Maintainer review and follow-up implementation are credited in the article. Evidence was inspected on 25 October 2026; upstream tests were not rerun for this publication.
