Hulu · Engineering case study

Bringing ESPN+ into
the Hulu experience.

ESPN+ integration engineering: content contracts, playback boundaries, asynchronous carousel loading, and development and testing ownership.

ESPN+ integrationContent discoveryLazy loadingDevelopment requirementsTesting ownership

My role: ESPN+ integration and delivery ownership

I was hired by Hulu to work on the ESPN+ integration. I implemented a lazy-loading carousel and managed and owned the development and testing requirements for my scope. That combined hands-on frontend implementation with responsibility for defining observable behavior, resolving requirement ambiguity, and establishing what needed to be validated before launch.

This technical walkthrough explains the architecture and failure modes relevant to that work. Code, contracts, state machines, and test scenarios below are illustrative engineering examples, rather than Hulu source code or a reconstruction of its private infrastructure.

Integration boundaries and data contracts

A sports integration crosses distinct domains: catalog discovery, event scheduling, account identity, subscription entitlement, playback authorization, and telemetry. Treating these as one boolean such as canWatch loses the distinction between a discoverable event, an available event, and a viewer authorized to play it.

The discovery layer should normalize provider metadata into a stable presentation contract. An event identifier must remain stable across schedule updates; artwork, availability windows, and playback identifiers have separate lifecycles. Time values should have an explicit UTC representation, with local formatting confined to presentation. A scheduled start time is not sufficient evidence that a stream is live.

Illustrative, simplified code explaining the implementation pattern. This is not a copy of private production source.

type SportsCard = {
  id: string;
  revision: string;
  title: string;
  eventState: "scheduled" | "live" | "ended";
  playbackKind: "live" | "replay" | "vod";
  startsAt: string;              // ISO 8601 UTC
  endsAt?: string;
  playbackAssetId?: string;
  artwork: { url: string; width: number; height: number };
};

type CatalogPage = {
  items: SportsCard[];
  nextCursor: string | null;     // Opaque server-issued cursor
  catalogVersion: string;
};

Cursor pagination needs an ordering contract. Concurrent catalog changes can otherwise duplicate or skip cards between pages. Merge pages by stable content ID, preserve server ordering, and invalidate the cursor chain when its catalog version changes. Empty results, transport failures, and malformed records must produce different states. Optional artwork can fall back; missing playback identifiers must not silently become playable cards.

Entitlement and playback orchestration

Catalog visibility is a discovery decision. Entitlement is an access decision, and playback authorization belongs on the server. The client can render subscription messaging, but must not grant access based on a cached flag or the presence of a stream URL.

A robust handoff resolves identity, evaluates current access, requests a playback session, and initializes the player only after authorization succeeds. Session responses may include a short-lived manifest URL and DRM configuration. Tokens should be scoped and expire; raw authorization tokens and signed URLs should never become analytics payloads.

For encrypted browser playback, the player may negotiate a supported key system through Encrypted Media Extensions, acquire a license, and associate keys with the media element. HLS or DASH selection, codec support, DRM compatibility, and live-edge behavior are player integration concerns. These describe the engineering boundary, without claiming which player or DRM systems Hulu used in my project.

Illustrative, simplified code explaining the implementation pattern. This is not a copy of private production source.

type PlaybackState =
  | "idle" | "authenticating" | "authorizing"
  | "initializing" | "playing" | "blocked" | "failed";

let playbackGeneration = 0;
let activeRequest: AbortController | undefined;

async function startPlayback(assetId: string) {
  const generation = ++playbackGeneration;
  activeRequest?.abort();
  const controller = new AbortController();
  activeRequest = controller;
  setPlaybackState("authorizing");

  try {
    const response = await fetch("/api/playback/sessions", {
      method: "POST",
      credentials: "same-origin",
      signal: controller.signal,
      headers: {
        "Content-Type": "application/json",
        "X-CSRF-Token": getCsrfToken()
      },
      body: JSON.stringify({ assetId })
    });
    if (generation !== playbackGeneration) return;
    if (response.status === 401 || response.status === 403) {
      setPlaybackState("blocked");
      return;
    }
    if (!response.ok) throw new Error("Session creation failed");
    const session = await response.json();
    if (generation !== playbackGeneration) return;
    setPlaybackState("initializing");
    // Validate session schema; player adapter owns DRM and readiness.
    await player.prepare(session, { signal: controller.signal });
    if (generation !== playbackGeneration) return;
    setPlaybackState("playing");
  } catch (error) {
    if (generation !== playbackGeneration || controller.signal.aborted) return;
    setPlaybackState("failed");
  }
}

Cancellation and generation checks solve different problems. Aborting reduces unnecessary work; the generation check prevents a stale response from committing after a newer selection. Player preparation needs the same cancellation discipline and teardown of old media sessions. A production implementation must also map expired sessions, geographic restrictions, concurrency limits, license failures, and unavailable events into explicit recoverable or blocked outcomes.

Lazy-loading carousel: three separate budgets

The carousel I implemented focused on deferring work until content was needed. Architecturally, a carousel has three independently controllable costs: metadata retrieval, artwork transfer and decode, and DOM rendering. Lazy-loading images does not automatically paginate catalog data or virtualize elements. Those policies should be evaluated separately.

The initial viewport should have sufficient metadata and correctly sized artwork to become useful quickly. Additional pages can be fetched near the end of loaded content, while artwork is requested shortly before cards enter the horizontal viewport. Virtualization becomes useful when mounted card count and layout work grow large, but requires preserving focus and scroll position.

Viewport-relative artwork scheduling

An observer rooted in the scroll container measures horizontal visibility against the carousel rather than the entire document. A positive horizontal root margin creates a prefetch buffer. Choose that buffer using scroll velocity and fetch/decode latency, with an approximate lead distance of velocity × latency + safety margin. Cap the buffer and concurrent requests to avoid downloading the whole rail.

Illustrative, simplified code explaining the implementation pattern. This is not a copy of private production source.

function observeArtwork(track: HTMLElement) {
  const observer = new IntersectionObserver(entries => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      const image = entry.target as HTMLImageElement;
      const source = image.dataset.src;
      if (!source) continue;
      observer.unobserve(image);
      image.addEventListener("error", () => {
        image.classList.add("artwork-failed"); // Reveal reserved fallback
      }, { once: true });
      image.src = source;
      delete image.dataset.src;
    }
  }, {
    root: track,
    rootMargin: "0px 320px 0px 320px",
    threshold: 0
  });
  track.querySelectorAll<HTMLImageElement>("img[data-src]")
    .forEach(image => observer.observe(image));
  return () => observer.disconnect();
}

Reserve intrinsic dimensions or an aspect ratio before loading to avoid layout shifts. Use responsive image candidates to match the displayed size and device pixel ratio. Image decode remains a separate cost from network transfer. Avoid assigning high fetch priority to every card; offscreen requests should not compete with the initial visible experience. Observe newly appended cards, and disconnect observers when the rail unmounts.

Pagination, deduplication, and stale results

Repeated intersection callbacks can trigger duplicate page requests. A single-flight guard permits one page request per rail, while a terminal cursor prevents endless requests at the boundary. Query changes require invalidating the previous generation, aborting its request, and resetting the merge state. Request cancellation alone is insufficient protection against a result that has already resolved.

Illustrative, simplified code explaining the implementation pattern. This is not a copy of private production source.

let generation = 0;
let pending: AbortController | null = null;
let cursor: string | null = null;
let exhausted = false;
const seen = new Set<string>();

function resetRail() {
  generation++;
  pending?.abort();
  pending = null;
  cursor = null;
  exhausted = false;
  seen.clear();
  replaceCards([]);
}

async function loadNextPage(query: string) {
  if (pending || exhausted) return;
  const version = generation;
  const controller = new AbortController();
  pending = controller;
  try {
    const page = await catalog.fetchPage({
      query, cursor, signal: controller.signal
    });
    if (version !== generation) return;
    const additions = page.items.filter(item => {
      if (seen.has(item.id)) return false;
      seen.add(item.id);
      return true;
    });
    appendCards(additions);
    cursor = page.nextCursor;
    exhausted = cursor === null;
  } catch (error) {
    if (version === generation && !controller.signal.aborted)
      showPageRetry();
  } finally {
    if (pending === controller) pending = null;
  }
}

Transient retries should be bounded, use jittered backoff, and respect server throttling. Authorization failures should not be retried as generic network failures. Keep already loaded content navigable during a failed page request. If version consistency is required, the catalog adapter must reject incompatible pages before mutating the rail.

Rendering and focus invariants

Use stable card keys rather than array positions. Batch updates instead of reading and writing layout repeatedly in a scroll handler. If virtualization is introduced, overscan should include the focused card, and offscreen removal must not strand keyboard focus. Next and previous buttons need accurate disabled states, accessible names, and predictable movement without automatic focus relocation.

Cache policy and freshness

Public catalog metadata and account-specific access data require different cache policies. Discovery cache keys should include every dimension that changes the response, such as locale, region, query, and catalog version. Account-specific responses must not leak across users through shared caches. Logout and subscription changes should invalidate viewer-specific state.

Live-event state needs tighter freshness than static artwork. Stale-while-revalidate can improve discovery responsiveness, but the playback endpoint must reauthorize against current server state. A cached card that still says “live” cannot guarantee stream availability or entitlement. Keep the cache boundary explicit so fast browsing does not become stale access control.

Development requirements as executable contracts

I managed and owned development requirements for my scope. Technical ownership means defining state transitions, dependency contracts, and failure behavior alongside visual requirements. Each requirement should identify its trigger, permitted state transition, side effects, and observable acceptance condition.

For example: a rail query change invalidates previous responses; repeated pagination triggers produce one in-flight request; a failed artwork request preserves card geometry; an unentitled selection cannot initialize playback. These requirements can become automated assertions instead of subjective interpretations of whether the feature appears to work.

Testing strategy: contracts, concurrency, and real browsing

I managed and owned testing requirements as well. The matrix below demonstrates the technical coverage appropriate to this integration, rather than reproducing Hulu’s internal test suite.

LayerFailure or invariantValidation
ContractMissing fields, invalid dates, unsupported content stateSchema fixtures and normalization tests; invalid items fail safely.
ConcurrencyOld query resolves after new query; repeated observer callbacksControlled promise ordering; assert no stale commit and one request per page.
PaginationDuplicate IDs, final cursor, failed next pageStable merge order, terminal state, bounded retries, existing cards preserved.
AuthorizationExpired session, signed-out viewer, access revokedNo player initialization on denial; server rechecks current access.
Player boundaryManifest failure, unsupported DRM, teardown during initializationAdapter contract tests; no abandoned session or stale state transition.
PerformanceFast scrolling, slow artwork, large catalogsNetwork waterfall, request count, long tasks, layout shifts, mounted-card count.
AccessibilityKeyboard traversal and rail boundariesFocus remains valid; controls expose labels and state.
RegressionExisting non-ESPN discovery and playbackRepresentative end-to-end journeys across shared components.

Illustrative, simplified code explaining the implementation pattern. This is not a copy of private production source.

// Illustrative deterministic race-condition test.
it("discards a page from a superseded query", async () => {
  const oldPage = deferred<CatalogPage>();
  const newPage = deferred<CatalogPage>();
  catalog.fetchPage
    .mockReturnValueOnce(oldPage.promise)
    .mockReturnValueOnce(newPage.promise);

  const oldLoad = loadNextPage("basketball");
  resetRail();
  const newLoad = loadNextPage("soccer");
  newPage.resolve(pageOf("soccer-1"));
  await newLoad;
  oldPage.resolve(pageOf("basketball-1"));
  await oldLoad;

  expect(renderedCardIds()).toEqual(["soccer-1"]);
});

The test intentionally allows an aborted request to resolve. That verifies the generation guard rather than merely trusting the mock transport to implement cancellation. Browser tests are also needed because DOM geometry, image decoding, keyboard focus, and intersection behavior cannot be fully validated by mocked unit tests.

Observability and launch readiness

A useful instrumentation model separates card impression, selection, authorization outcome, playback initialization, first frame, and playback failure. An impression should use an agreed visibility threshold and dwell duration rather than counting every mounted card. Correlate stages with a bounded interaction identifier while excluding tokens, signed URLs, and unnecessary personal data.

Measure browse-to-selection latency, authorization duration, time to first frame, request errors by category, and carousel loading failures. Compare distributions, especially tail latency, rather than relying only on averages. Establish baselines and release thresholds before rollout; no measured latency or performance improvements are asserted here without project data.

Feature flags, staged exposure, backward-compatible contracts, and a rollback path reduce launch uncertainty. Requirements ownership should make the release decision concrete: which journeys pass, which failure states remain, and what signals require rollback.

Commercial impact

Reported ESPN+ launch result
Over $50 million

First-month revenue, as reported from my experience. This is the broader launch result, not revenue attributed solely to my individual contribution.

My contribution connected implementation with delivery ownership: ESPN+ integration work, a lazy-loading carousel, and responsibility for development and testing requirements. Discovery performance and reliable access flows support conversion and viewing, but the launch revenue reflects the combined product, commercial, marketing, and engineering effort.