Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.discovr.media/llms.txt

Use this file to discover all available pages before exploring further.

You’ve built navigation and profiles. Now make your app feel fast. This guide walks through the loading patterns that keep Discovr apps responsive. The core idea is simple: load in priority order. Hero → rows → items. Show the user something immediately, then fill in the rest as they scroll.
Load phaseWhat you fetchWhen
ImmediategetPage() heroOn page open
After hero rendersgetRows() row shellsAfter hero visible
On scrollgetRowItems() for one rowWhen row enters viewport
PaginationMore items / rowsWhen user reaches end
This guide assumes you understand pages (hero + rows) and snapshots. If not, start with Pages Overview and Session management. Code snippets use TypeScript, Kotlin, and Swift tabs.

Page Load Strategy: Hero First

When the user opens a page, load the hero carousel immediately. Rows can come later.
  1. Fetch page metadata (hero) with getPage()
  2. Render the hero
  3. Set up Intersection Observers for rows
  4. Fetch rows on demand as the user scrolls
async function loadPageEfficiently(pageId: string) {
	// Step 1: Fetch hero (fast)
	const pageData = await discovr.getPage(pageId);
	renderHero(pageData.hero); // User sees hero immediately

	// Step 2: Fetch rows lazily (after user sees hero)
	const rows = await discovr.getRows(pageId);

	// Step 3: Set up Intersection Observer for each row
	rows.forEach((row) => {
		const observer = new IntersectionObserver((entries) => {
			entries.forEach(async (entry) => {
				if (entry.isIntersecting) {
					// Row is visible; fetch its items
					const items = await discovr.getRowItems(
						pageId,
						row.id
					);
					renderRow(row, items);
					observer.unobserve(entry.target);
				}
			});
		});

		const rowElement = document.getElementById(`row-${row.id}`);
		observer.observe(rowElement);
	});
}

loadPageEfficiently(homePageId);
Metrics: Measure “Time to Hero” (how long until the hero appears). This is the user’s first impression of load speed. 1-2 seconds is typical; anything over 3 seconds feels slow.
Page load timeline:

0ms    - User taps Home
200ms  - Fetch getPage() (hero data)
300ms  - HERO VISIBLE TO USER (KEY MILESTONE)
400ms  - Fetch getRows() (row metadata)
500ms  - Rows appear below hero
...
1200ms - Row 1 becomes visible
1300ms - Fetch getRowItems() for Row 1
1500ms - Row 1 items render
...
3000ms - Row 2 becomes visible
3100ms - Fetch getRowItems() for Row 2

Result: Hero in 300ms, rows by 500ms, items on demand.
Total bandwidth: only what's visible.

Row Item Pagination: Load on Scroll

Rows include pagination tokens (nextPageToken, hasMore). Don’t fetch all items upfront. Fetch them as the user scrolls within the row.
async function loadRowItemsOnScroll(pageId, row) {
	// Fetch the first page of items
	let items = await discovr.getRowItems(pageId, row.id);
	renderItems(items);

	// Watch for scroll near the end
	const container = document.getElementById(`row-${row.id}`);
	const observer = new IntersectionObserver((entries) => {
		entries.forEach(async (entry) => {
			if (
				entry.isIntersecting &&
				row.hasMore &&
				row.nextPageToken
			) {
				// User scrolled near the end; fetch next page
				const moreItems = await discovr.getRowItems(
					pageId,
					row.id,
					row.nextPageToken
				);
				items = items.concat(moreItems);
				renderItems(items);

				// Update pagination state
				row.nextPageToken = moreItems[moreItems.length - 1]
					?.nextPageToken;
				row.hasMore = moreItems.length > 0;
			}
		});
	});

	observer.observe(container);
}
Why? A row might have 100 items. The user might only see the first 10 before scrolling to a different row. Why download 90 items they’ll never see?

Image Lazy Loading

Media items include image URLs. Use the browser’s native loading="lazy" attribute or an Intersection Observer to defer image fetches until the image is near the viewport.
// Native lazy loading (simplest)
function renderMediaItem(item) {
	const img = document.createElement("img");
	img.src = item.imageUrl;
	img.loading = "lazy"; // Browser defers load until visible
	img.alt = item.title;
	return img;
}

// Or with Intersection Observer (more control)
function observeImageVisibility(img) {
	const observer = new IntersectionObserver((entries) => {
		entries.forEach((entry) => {
			if (entry.isIntersecting) {
				img.src = img.dataset.src; // Load on visibility
				observer.unobserve(img);
			}
		});
	});
	observer.observe(img);
}
Why? Images are heavy. Downloading 50 poster images (10MB+) upfront kills network speed and battery life. Lazy loading fetches them only when needed.
Set a reasonable image resolution for your app. If your thumbnail is 200x300 pixels, request that size from Discovr, not the full 4K poster. This halves (or more) the image file size.

Combined Pattern: Full Home Page

Here’s a complete example tying everything together:
async function loadHomePageEfficiently(homePageId: string) {
	// 1. Load hero immediately
	const pageData = await discovr.getPage(homePageId);
	renderHero(pageData.hero);

	// 2. Insert "Continue Watching" row from profile context
	const { items: continueWatching } = await discovr.getPlaybackItems({ limit: 10 });
	renderContinueWatchingRow(continueWatching);

	// 3. Fetch recommendation rows, but not their items yet
	const rows = await discovr.getRows(homePageId);

	// 4. Observe each row for visibility
	rows.forEach((row) => {
		const observer = new IntersectionObserver((entries) => {
			entries.forEach(async (entry) => {
				if (entry.isIntersecting && !row.itemsLoaded) {
					// Fetch items for visible row
					const items = await discovr.getRowItems(
						homePageId,
						row.id
					);
					renderRow(row, items);
					row.itemsLoaded = true;

					// Also observe for pagination
					observeRowEnd(row, homePageId);
				}
			});
		});

		const rowElement = document.getElementById(
			`row-${row.id}`
		);
		observer.observe(rowElement);
	});
}

async function observeRowEnd(row, pageId) {
	const observer = new IntersectionObserver((entries) => {
		entries.forEach(async (entry) => {
			if (
				entry.isIntersecting &&
				row.hasMore &&
				row.nextPageToken
			) {
				const moreItems = await discovr.getRowItems(
					pageId,
					row.id,
					row.nextPageToken
				);
				appendItems(row, moreItems);
				row.nextPageToken = moreItems[moreItems.length - 1]
					?.nextPageToken;
				row.hasMore = moreItems.length > 0;
			}
		});
	});

	const endMarker = document.getElementById(
		`row-${row.id}-end`
	);
	observer.observe(endMarker);
}

Interaction Performance

Profile Switching

Switching profiles creates a new session and snapshot. Don’t keep the old Home page DOM—clear it and reload. The new profile’s recommendations are completely fresh.
async function switchProfile(newProfileId: string) {
	// Clear the old home page
	document.getElementById("home-container").innerHTML = "";

	// Select new profile (creates new session)
	await discovr.selectProfile(newProfileId);

	// Load new home page
	const newHome = await discovr.createPage({ name: "Home" });
	await loadHomePageEfficiently(newHome.id);
}

Tab Switching (Home/Movies/Series)

Each tab is a separate page. When the user taps a tab, fetch that page’s data (if not already cached).
const tabPages = {
	home: null,
	movies: null,
	series: null,
};

async function switchTab(tab: "home" | "movies" | "series") {
	// If already loaded, reuse
	if (tabPages[tab]) {
		renderPage(tabPages[tab]);
		return;
	}

	// Otherwise, load from cache (getPage) doesn't require network
	const pageData = await discovr.getPage(tabPageIds[tab]);
	tabPages[tab] = pageData;
	renderPage(pageData);
}
When the user taps a navigation row item, create a new page (which takes a moment), then load it. Show a loading indicator while fetching:
async function drillIntoGenre(genreFilter) {
	showLoadingIndicator();

	const newPage = await discovr.createPage({
		pageFilters: genreFilter,
	});
	await loadPageEfficiently(newPage.id);

	hideLoadingIndicator();
}

Error Recovery & Stale Data

If a row fetch fails (network error), show an error state with a retry button. Don’t pre-fetch rows that might go stale—lazy loading prevents this by fetching only when needed.
async function loadRowItemsWithErrorHandling(pageId, row) {
	try {
		const items = await discovr.getRowItems(pageId, row.id);
		renderRow(row, items);
	} catch (err) {
		renderRowError(row);
		row.retryCallback = () =>
			loadRowItemsWithErrorHandling(pageId, row);
	}
}

Metrics to Measure

Track these to ensure your lazy loading is working:
  • Time to Hero: When does the hero appear? (Target: under 1s)
  • Time to First Row: When does the first row become visible? (Target: under 2s)
  • Total Page Load: When is the page fully interactive? (Target: under 3s)
  • Bandwidth per Page: Compare lazy vs. eager loading. Lazy should use 40-60% less bandwidth.
  • Scroll Smoothness: Measure frame rate. Lazy loading should prevent jank (60 FPS on modern devices).
Use your browser’s Network tab or mobile profiler to measure. For iOS, use Xcode’s System Trace. For Android, use Android Profiler. Look for network waterfalls and ensure hero fetches complete quickly.

Next: Error handling