Skip to main content
OlamOTT
OTT Engineering

What Happens After You Press Play? The Event-Driven Lifecycle of an OTT Session

A practical walkthrough of how an OTT client coordinates identity, entitlement, playback authorization, DRM, player events, recovery, analytics, and cleanup after a viewer presses Play.

Reading time
12 min read
Published date
Published 2026-07-27
OTTPlaybackSmart TVEvent-Driven ArchitectureDRMFrontend Architecture

Play is an intent, not an instruction to the decoder

A viewer highlights a movie and presses Play. The button handler may be only a few lines long, but depending on the session and playback state, the resulting work may involve the application UI, identity service, entitlement service, playback API, DRM system, content delivery network, player engine, and analytics pipeline.

They do not complete together. A response can arrive after the viewer backs out, a token can expire between requests, and a player can report buffering after the application enters the background. On a Smart TV, the operating system may suspend the application while a DRM request is in flight.

The OTT client is therefore not a passive shell around a video player. It is an active coordinator of a distributed, asynchronous session. Its job is to turn user intent and external callbacks into a lifecycle the product can reason about.

A typical successful path might look like this:

PLAY_REQUESTED
  -> SESSION_VALIDATED
  -> ENTITLEMENT_GRANTED
  -> PLAYBACK_CONFIG_RECEIVED
  -> MANIFEST_LOADED
  -> DRM_READY
  -> PLAYBACK_STARTED

The sequence makes the work visible; platforms need not emit every milestone or order them identically.

State, event, command, and side effect are different things

These four concepts are often mixed together in playback code. Keeping them separate makes races, retries, and cleanup easier to control.

State is what the client currently believes about the session: checking-entitlement, acquiring-license, playing, buffering, failed, or terminated. State should include the identifiers and counters needed to interpret new input, such as the active session ID and current retry count.

An event is a fact presented to the lifecycle: PLAY_REQUESTED, TOKEN_REFRESH_SUCCEEDED, PLAYER_STALLED, or APP_SUSPENDED. Events may come from the UI, a service response, a timer, the operating system, or a player adapter. An event describes what happened; it does not order the next piece of external work.

A command expresses work the lifecycle wants performed: CHECK_ENTITLEMENT, REFRESH_TOKEN, CREATE_PLAYER, or SCHEDULE_RETRY. It is a decision produced from the current state and an event.

A side effect is the actual interaction with the outside world: making the entitlement request, writing an analytics record, calling a platform media API, or starting a timer. Side effects can succeed, fail, time out, or finish late. Their outcomes return as new events.

The useful loop is:

current state + event
  -> transition
  -> next state + commands
  -> side-effect handlers
  -> new events

This separation prevents a reducer or transition function from quietly starting requests. More importantly, it gives the client one place to decide whether a late result still belongs to the active session.

Startup crosses several authorization boundaries

“The user is authorized” is too vague for a playback failure. At least four different decisions can appear in the path.

  • Authentication establishes who the user is. For protected requests, the client also needs a usable application session and valid credentials. OAuth access tokens commonly participate here, but the playback coordinator should not become the owner of a complete OAuth implementation.
  • Authorization or entitlement answers whether that account may watch the selected content under its subscription, purchase, territory, profile, concurrency, or other product rules.
  • Playback authorization asks the video platform for the concrete playback session: manifest URL, protection data, session policy, CDN or delivery information, and often short-lived playback credentials.
  • DRM authorization asks a DRM license service whether this client and device may obtain the keys required to decrypt protected media.

These checks are related, but one success does not imply the others. A signed-in subscriber can lack a sports add-on. An entitled account can receive a playback denial because its concurrency limit was reached. A valid playback response can still lead to a DRM license failure on an unsupported or non-compliant device.

Keeping the failures distinct improves both the user message and the evidence available to operations. “Please sign in,” “Subscribe to watch,” and “This title cannot be played on this device” represent different recovery paths.

Authentication is a branch inside the playback lifecycle

When Play is requested, the client first needs a usable application session. If there is no signed-in user, playback should pause at a recoverable boundary and hand control to the existing login experience.

PLAY_REQUESTED
  -> AUTHENTICATION_REQUIRED
  -> LOGIN_PRESENTED
  -> LOGIN_SUCCEEDED
  -> SESSION_VALIDATED

The client should retain a safe playback intent, such as content ID and intended start position, rather than a stale manifest URL or token. After login, it resumes from a defined checkpoint. If login is cancelled or fails, the playback session terminates without creating a player.

An access token can also expire during startup. A protected API returning an authentication-specific unauthorized response can trigger one controlled refresh:

API_UNAUTHORIZED
  -> TOKEN_REFRESH_REQUESTED
  -> TOKEN_REFRESH_SUCCEEDED
  -> ORIGINAL_REQUEST_RETRIED

The retry must use a newly obtained token and preserve the operation it is repeating. If refresh fails, or the retried request is unauthorized again, the result is an authentication failure—not an invitation to refresh forever. Clear the unusable identity state according to application policy, show the sign-in path, and close the pending playback attempt.

This is enough OAuth knowledge for the coordinator. Token storage, refresh-token rotation, device authorization, and account UI belong behind the application’s identity boundary.

Entitlement can change while the viewer is waiting

Once the user is authenticated, the client checks whether the account can watch the content. A denial is often a product state rather than a technical error.

ENTITLEMENT_DENIED
  -> OFFER_PRESENTED
  -> PURCHASE_REQUESTED
  -> PURCHASE_SUCCEEDED
  -> ENTITLEMENT_RECHECK_REQUESTED
  -> ENTITLEMENT_GRANTED

The purchase or subscription result should not be treated as entitlement by itself. Billing confirmation and the entitlement read model may update through different services. Rechecking gives the playback path an authoritative answer and handles delayed propagation explicitly.

That recheck still needs a bound. A product might retry after a short delay a small number of times, then offer a manual retry or support path. An endless spinner hides a consistency problem and leaves the viewer uncertain about whether the purchase worked.

If the viewer dismisses the offer, switches profiles, or navigates to another title, terminate the original session. Any purchase completion that arrives later can update account UI, but it must not unexpectedly start the old video.

Playback configuration creates the media session boundary

After entitlement is granted, the client requests playback configuration. This is frequently where content selection becomes a concrete, time-limited media session.

The response may contain a manifest URL, content type, live or on-demand flags, DRM configuration, request headers, heartbeat rules, ad information, start-position policy, and player tuning selected for the platform. Treat this object as session-scoped. Do not cache and reuse it as if it were permanent catalog metadata.

The client then creates a platform player or prepares an existing player instance, attaches normalized listeners, and loads the manifest. Depending on the player and DRM system, license acquisition may begin before or during manifest/media loading. The lifecycle needs meaningful milestones without pretending that all platforms order their low-level callbacks identically.

For clear content, MANIFEST_LOADED may lead directly toward readiness. For protected content:

DRM_CHALLENGE_CREATED
  -> LICENSE_REQUESTED
  -> LICENSE_RECEIVED
  -> DRM_READY

The failure path should be equally visible:

LICENSE_REQUESTED
  -> LICENSE_DENIED
  -> DRM_FAILURE_CLASSIFIED
  -> PLAYBACK_TERMINATED

A license failure should retain a safe diagnostic category: network failure, server denial, unsupported protection, expired playback credentials, or platform DRM failure. Do not log license challenges, tokens, keys, or sensitive response bodies. Retry only failures classified as temporary and only when the playback authorization remains valid.

Normalize player callbacks into playback-domain events

Player APIs speak different dialects. One engine reports waiting; another reports a buffer level; a native TV API reports numeric state codes. Some engines emit playing more than once, report an error after teardown, or use the same callback for initial loading and a mid-stream stall.

The rest of the application should not have to understand those details. A platform adapter can combine raw callbacks with lifecycle context and emit stable events such as:

  • MANIFEST_LOADED
  • FIRST_FRAME_RENDERED
  • PLAYBACK_STARTED
  • PLAYBACK_STALLED
  • PLAYBACK_RECOVERED
  • PLAYBACK_ENDED
  • PLAYBACK_FAILED

Normalization is not merely renaming. The adapter may need to deduplicate callbacks, classify an error, distinguish startup buffering from rebuffering, and attach current position or safe platform diagnostics. Keep the raw code available in restricted diagnostic context, but do not make product logic branch on dozens of vendor error numbers.

Nor should every normalized player event become an analytics event. Time updates, buffer changes, bitrate switches, and duplicate playing callbacks can be high-frequency implementation signals. Analytics should record events that answer defined product or QoE questions—play request, startup, first frame, qualified stall, recovery, completion, abandonment, and terminal failure—with explicit sampling and deduplication rules.

Operational telemetry can remain more detailed than business analytics. Sharing a session ID and timestamps lets the two streams be correlated without forcing them to have identical schemas.

Keep transition logic pure and external work replaceable

A transition function can decide what should happen without performing an API call or constructing a player. The example below is intentionally small; a production model would include more states and error detail.

type State = {
  phase: "idle" | "authorizing" | "refreshing" | "loading" | "playing" | "failed";
  sessionId?: string;
  tokenRefreshes: number;
};

type Event =
  | { type: "PLAY_REQUESTED"; sessionId: string }
  | { type: "API_UNAUTHORIZED"; sessionId: string }
  | { type: "TOKEN_REFRESH_SUCCEEDED"; sessionId: string }
  | { type: "PLAYBACK_CONFIG_RECEIVED"; sessionId: string }
  | { type: "PLAYER_PLAYING"; sessionId: string }
  | { type: "FATAL_ERROR"; sessionId: string };

type Command =
  | { type: "REQUEST_PLAYBACK_CONFIG"; sessionId: string }
  | { type: "REFRESH_TOKEN"; sessionId: string }
  | { type: "CREATE_PLAYER"; sessionId: string }
  | { type: "CLEANUP"; sessionId: string };

type Transition = { state: State; commands: Command[] };

function assertNever(_event: never): never {
  throw new Error("Unhandled playback event");
}

function transition(state: State, event: Event): Transition {
  if ("sessionId" in event && state.sessionId && event.sessionId !== state.sessionId) {
    return { state, commands: [] };
  }

  switch (event.type) {
    case "PLAY_REQUESTED":
      return {
        state: { phase: "authorizing", sessionId: event.sessionId, tokenRefreshes: 0 },
        commands: [{ type: "REQUEST_PLAYBACK_CONFIG", sessionId: event.sessionId }],
      };
    case "API_UNAUTHORIZED":
      if (state.tokenRefreshes >= 1) {
        return {
          state: { ...state, phase: "failed" },
          commands: [{ type: "CLEANUP", sessionId: event.sessionId }],
        };
      }
      return {
        state: { ...state, phase: "refreshing", tokenRefreshes: 1 },
        commands: [{ type: "REFRESH_TOKEN", sessionId: event.sessionId }],
      };
    case "TOKEN_REFRESH_SUCCEEDED":
      return {
        state: { ...state, phase: "authorizing" },
        commands: [{ type: "REQUEST_PLAYBACK_CONFIG", sessionId: event.sessionId }],
      };
    case "PLAYBACK_CONFIG_RECEIVED":
      return {
        state: { ...state, phase: "loading" },
        commands: [{ type: "CREATE_PLAYER", sessionId: event.sessionId }],
      };
    case "PLAYER_PLAYING":
      return { state: { ...state, phase: "playing" }, commands: [] };
    case "FATAL_ERROR":
      return {
        state: { ...state, phase: "failed" },
        commands: [{ type: "CLEANUP", sessionId: event.sessionId }],
      };
    default:
      return assertNever(event);
  }
}

Command handlers perform the external work and dispatch outcome events. That makes transition tests fast and deterministic, while API, DRM, timers, analytics, and player adapters can be tested at their own boundaries.

Session IDs protect the new session from the old one

Imagine the viewer presses Play on episode 3, backs out, and immediately starts episode 4. The entitlement response for episode 3 arrives last. Without session identity, it can overwrite current state, create a second player, or replace the new error message.

Create a unique playback session ID at PLAY_REQUESTED and attach it to commands, callbacks, timers, logs, and analytics. Before applying any asynchronous result, compare its ID with the active session. A mismatch means the result is stale and should be ignored or used only for non-session account updates.

Cancellation APIs are still valuable, but cancellation alone is not enough. Some platform calls cannot be cancelled, and a cancelled request can race with completion. Session checking is the final guard.

The ID also provides a correlation spine across the client, playback service, license request metadata where appropriate, and telemetry. It should be an opaque identifier, not an encoded user ID or token.

Buffering and temporary failures are states, not immediate defeat

After playback begins, a stall should move the UI into buffering only after the product’s chosen threshold. Very short buffer transitions may be normal player behavior and should not flash a spinner or create a QoE event.

PLAYER_STALLED
  -> BUFFERING_CONFIRMED
  -> RECOVERY_TIMER_STARTED
  -> PLAYER_RECOVERED
  -> PLAYBACK_RESUMED

If the player recovers, record the stall duration and continue the same session. If it does not recover within the allowed window, the client may request a player-supported recovery, reload the media session, or fail. Live streams and on-demand assets may need different choices because a live reload can change position and latency.

Temporary network failures also need classification. A timeout or transient connectivity loss may justify retry; an entitlement denial, unsupported codec, or malformed manifest usually does not. Every retry policy should define:

  • Which operation and error classes are retryable.
  • Maximum attempts or maximum elapsed time.
  • Delay and backoff, with jitter where many clients could retry together.
  • Whether the operation is safe to repeat.
  • What terminal event occurs when the budget is exhausted.

Retries must be bounded because an infinite loop consumes bandwidth and memory, extends a spinner indefinitely, can overload recovering services, and prevents the lifecycle from reaching cleanup. A bounded failure is observable and actionable.

Fatal failure should end in one controlled place

A fatal playback failure may come from exhausted network recovery, DRM denial, invalid playback configuration, an unsupported platform capability, or a terminal player error. Multiple low-level errors can arrive for the same incident, so termination should be idempotent.

The first accepted fatal event moves the session to failed, captures the phase and safe diagnostic context, selects an appropriate user message, emits one terminal telemetry outcome, and begins cleanup. Later errors from that session do not reopen it or duplicate the failure analytics.

Recovery offered to the viewer should create a new session rather than resetting scattered flags on the failed one. A new session gets fresh authorization, retry budgets, timestamps, listeners, and correlation identity.

Termination is part of playback, not housekeeping

Sessions end through completion, Back navigation, content switching, logout, application shutdown, suspension policy, or failure. All paths should converge on explicit, safe-to-repeat cleanup.

STOP_REQUESTED
  -> SESSION_TERMINATING
  -> PENDING_WORK_CANCELLED
  -> PLAYER_DESTROYED
  -> FINAL_ANALYTICS_RECORDED
  -> SESSION_TERMINATED

Cleanup may need to:

  1. Stop timers, heartbeats, retries, and analytics intervals.
  2. Unsubscribe service, application-lifecycle, and player listeners.
  3. Pause or stop playback and detach the media source.
  4. Destroy the player and release DRM or platform media resources.
  5. Clear session-scoped URLs, headers, configuration, and references.
  6. Emit the appropriate terminal outcome once.
  7. Mark the session terminated so late callbacks are ignored.

This matters everywhere, but especially on Smart TVs. Limited memory, long application lifetimes, old browser engines, and platform player objects that hold native resources make leaks cumulative. Leaving one media element, listener, or decoder allocation behind can break the next playback attempt rather than the current one.

Suspension adds another boundary. Some platforms pause JavaScript, some continue media, and some reclaim the player. On suspend, record enough state to apply a documented policy and stop work that must not continue. On resume, revalidate network, identity, playback authorization, and platform player state as needed. Do not assume an old manifest URL, token, license, timer, or callback queue is still valid.

Older browser engines can also make modern concurrency helpers or cancellation behavior inconsistent. Platform-specific player APIs may demand teardown calls in a strict order or reject a new player until the old one releases its native handle. Keep these quirks in adapters, but represent their outcomes in the same session lifecycle.

Design the lifecycle around evidence and ownership

An event-driven playback lifecycle does not require one particular state-machine library or a universal sequence for every OTT product. It requires explicit decisions: which state the client owns, which events it trusts, which commands it issues, how side effects report back, and when a session is no longer allowed to act.

Start by mapping the real happy path and the failure paths that currently become spinners. Give authentication, entitlement, playback authorization, and DRM authorization distinct names. Normalize player callbacks. Add a session ID before adding clever recovery. Put limits on retries. Make cleanup a first-class transition and test it on the weakest supported TV.

When those boundaries are visible, pressing Play stops being a mysterious chain of promises. It becomes a lifecycle the client can coordinate, observe, recover, and terminate deliberately—even when the network, services, player, and device do not behave in a neat sequence.