Every Page Load Is a Question

Parts 1 and 2 built the data layer and management interface. Now we build the engine, the code that runs on every page load and decides which ad to show in each placement. This is the core value of the entire system.

The ad resolver must answer a simple question fast: given a placement and a user’s context, which creative should appear? “Fast” means under 10ms. “Context” includes the user’s device, location, the current time, and how many times they have already seen this ad. The answer must respect campaign budgets, ad group priorities, creative weights, and every targeting rule configured through the forms we built in Part 2.

This article implements the complete resolution pipeline, the targeting filter system, the weighted selection algorithm, the AdSlot component that renders creatives, and the server-side infrastructure that ties it all together.


The Resolution Pipeline

Here is the complete pipeline that transforms a placement request into a rendered creative:

Loading diagram...

Each stage is a pure function that takes a list of candidates and returns a smaller list. This composable design makes each filter independently testable and the pipeline easy to extend.


User Context

Before any filtering can happen, we need to know about the user. The AdRequestContext captures everything the pipeline needs:

// src/lib/types/ads.ts (additions)

export interface AdRequestContext {
	/** The placement slug being requested */
	placement: string
	/** The URL of the page requesting ads */
	pageUrl: string
	/** Detected device type */
	device: DeviceType
	/** ISO 3166-1 alpha-2 country code */
	country: string
	/** Region code (e.g. 'US-CA') */
	region: string
	/** Current time in UTC */
	now: Date
	/** Anonymous user/session identifier for frequency capping */
	sessionId: string
	/** Authenticated user ID, if available */
	userId: string | null
}

We extract this context from the SvelteKit request in a helper:

// src/lib/server/ads/context.ts

import type { RequestEvent } from '@sveltejs/kit'
import type { AdRequestContext, DeviceType } from '$lib/types/ads'

/**
 * Extract ad request context from a SvelteKit request event.
 * In production, geo data comes from your CDN headers (Cloudflare, Vercel, etc.).
 */
export function extractAdContext(event: RequestEvent, placement: string): AdRequestContext {
	const { request, cookies, url } = event

	const userAgent = request.headers.get('user-agent') ?? ''
	const device = detectDevice(userAgent)

	// Geo detection - in production, use CDN headers
	// Cloudflare: cf-ipcountry, Vercel: x-vercel-ip-country
	const country = (
		request.headers.get('cf-ipcountry') ??
		request.headers.get('x-vercel-ip-country') ??
		'US'
	).toUpperCase()

	const region =
		request.headers.get('cf-region-code') ?? request.headers.get('x-vercel-ip-country-region') ?? ''

	// Session ID from cookie, or generate one
	let sessionId = cookies.get('ad_session')
	if (!sessionId) {
		sessionId = crypto.randomUUID()
		cookies.set('ad_session', sessionId, {
			path: '/',
			httpOnly: true,
			secure: true,
			sameSite: 'lax',
			maxAge: 60 * 60 * 24 * 30 // 30 days
		})
	}

	return {
		placement,
		pageUrl: url.pathname,
		device,
		country,
		region: region ? `${country}-${region}` : '',
		now: new Date(),
		sessionId,
		userId: event.locals.userId ?? null
	}
}

function detectDevice(userAgent: string): DeviceType {
	if (/tablet|ipad|playbook|silk/i.test(userAgent)) return 'tablet'
	if (/mobile|iphone|ipod|android.*mobile|opera mini/i.test(userAgent)) return 'mobile'
	return 'desktop'
}
Geo Detection in Production

Most CDN providers inject geolocation headers into requests. Cloudflare adds cf-ipcountry and cf-region-code. Vercel adds x-vercel-ip-country and x-vercel-ip-country-region. For local development, the fallback to 'US' is fine. In production, use your CDN’s headers.


Targeting Filters

Each filter is a function with the same signature: it takes an ad group and context, and returns true if the ad group should be included.

// src/lib/server/ads/targeting.ts

import type { AdGroup, AdRequestContext, TargetingRules } from '$lib/types/ads'

// ─── Individual Filter Functions ──────────────────────────

/**
 * Check if the ad group targets the requested placement.
 */
export function matchesPlacement(targeting: TargetingRules, placement: string): boolean {
	return targeting.placements.includes(placement)
}

/**
 * Check if the ad group targets the user's device type.
 * 'all' matches every device.
 */
export function matchesDevice(targeting: TargetingRules, device: string): boolean {
	if (targeting.devices.includes('all')) return true
	return targeting.devices.includes(device as any)
}

/**
 * Check if the ad group targets the user's country.
 * Empty array means "all countries" (no geo restriction).
 */
export function matchesGeo(targeting: TargetingRules, country: string, region: string): boolean {
	// No geo restrictions - matches everywhere
	if (targeting.geoCountries.length === 0 && targeting.geoRegions.length === 0) {
		return true
	}

	// Check country-level targeting
	if (targeting.geoCountries.length > 0 && !targeting.geoCountries.includes(country)) {
		return false
	}

	// Check region-level targeting (more specific)
	if (targeting.geoRegions.length > 0 && !targeting.geoRegions.includes(region)) {
		return false
	}

	return true
}

/**
 * Check if the current time falls within the ad group's schedule.
 * Uses the ad group's configured timezone.
 */
export function matchesSchedule(targeting: TargetingRules, now: Date): boolean {
	const { scheduleTimezone, scheduleDays, scheduleHoursStart, scheduleHoursEnd } = targeting

	// Convert current time to the ad group's timezone
	const formatter = new Intl.DateTimeFormat('en-US', {
		timeZone: scheduleTimezone,
		weekday: 'short',
		hour: 'numeric',
		hour12: false
	})

	const parts = formatter.formatToParts(now)
	const dayName = parts.find((p) => p.type === 'weekday')?.value ?? ''
	const hour = Number(parts.find((p) => p.type === 'hour')?.value ?? 0)

	// Map day name to number (0 = Sunday)
	const dayMap: Record<string, number> = {
		Sun: 0,
		Mon: 1,
		Tue: 2,
		Wed: 3,
		Thu: 4,
		Fri: 5,
		Sat: 6
	}
	const dayNumber = dayMap[dayName] ?? 0

	// Check day
	if (scheduleDays.length > 0 && !scheduleDays.includes(dayNumber)) {
		return false
	}

	// Check hour range
	if (scheduleHoursStart <= scheduleHoursEnd) {
		// Normal range: e.g. 8–22
		if (hour < scheduleHoursStart || hour > scheduleHoursEnd) return false
	} else {
		// Overnight range: e.g. 22–6 (wraps midnight)
		if (hour < scheduleHoursStart && hour > scheduleHoursEnd) return false
	}

	return true
}

/**
 * Check if the page URL matches any of the ad group's URL patterns.
 * Empty array means "all pages."
 */
export function matchesUrl(targeting: TargetingRules, pageUrl: string): boolean {
	if (targeting.urlPatterns.length === 0) return true

	return targeting.urlPatterns.some((pattern) => {
		try {
			return new RegExp(pattern).test(pageUrl)
		} catch {
			// Invalid regex - skip this pattern
			return false
		}
	})
}

// ─── Combined Filter ──────────────────────────────────────

/**
 * Apply all targeting rules to an ad group.
 * Returns true if the ad group matches the request context.
 */
export function matchesTargeting(adGroup: AdGroup, context: AdRequestContext): boolean {
	const { targeting } = adGroup

	return (
		matchesPlacement(targeting, context.placement) &&
		matchesDevice(targeting, context.device) &&
		matchesGeo(targeting, context.country, context.region) &&
		matchesSchedule(targeting, context.now) &&
		matchesUrl(targeting, context.pageUrl)
	)
}
Filter Ordering Matters for Performance

The filters are ordered from cheapest to most expensive. matchesPlacement is a simple array includes() check. matchesSchedule creates an Intl.DateTimeFormat instance and parses parts. By putting cheap filters first in the && chain, JavaScript’s short-circuit evaluation skips expensive checks when a cheap one already fails.


Budget Checking

Budget checks determine whether a campaign has remaining spend available:

// src/lib/server/ads/budget.ts

import type { Campaign } from '$lib/types/ads'
import { db } from '$lib/server/db/client'
import { adEvents } from '$lib/server/db/schema'
import { eq, and, gte, sql } from 'drizzle-orm'

/**
 * Check if a campaign is within its budget.
 */
export function isWithinBudget(campaign: Campaign): boolean {
	if (campaign.budgetType === 'lifetime') {
		return campaign.budgetSpent < campaign.budgetAmount
	}

	// For daily budgets, we compare against a rolling 24-hour spend.
	// The actual daily spend tracking is maintained by the event tracker
	// and reset at midnight in the campaign's timezone.
	return campaign.budgetSpent < campaign.budgetAmount
}

/**
 * Get today's spend for a campaign (for daily budget campaigns).
 * This queries the events table for impressions/clicks since midnight UTC.
 */
export async function getDailySpend(campaignId: string): Promise<number> {
	const todayStart = new Date()
	todayStart.setUTCHours(0, 0, 0, 0)

	const result = await db
		.select({ count: sql<number>`count(*)` })
		.from(adEvents)
		.where(and(eq(adEvents.campaignId, campaignId), gte(adEvents.timestamp, todayStart)))

	// In a real system, each event would have a cost.
	// For simplicity, we count events as cost = 1 cent each.
	return result[0]?.count ?? 0
}

Frequency Capping

Frequency capping prevents ad fatigue by limiting how often a user sees the same ad group’s creatives:

// src/lib/server/ads/frequency.ts

import { db } from '$lib/server/db/client'
import { adEvents } from '$lib/server/db/schema'
import { eq, and, gte, sql } from 'drizzle-orm'

/**
 * Get the number of impressions a session has received
 * from a specific ad group today.
 */
export async function getSessionImpressions(sessionId: string, adGroupId: string): Promise<number> {
	const todayStart = new Date()
	todayStart.setUTCHours(0, 0, 0, 0)

	const result = await db
		.select({ count: sql<number>`count(*)` })
		.from(adEvents)
		.where(
			and(
				eq(adEvents.sessionId, sessionId),
				eq(adEvents.adGroupId, adGroupId),
				eq(adEvents.type, 'impression'),
				gte(adEvents.timestamp, todayStart)
			)
		)

	return result[0]?.count ?? 0
}

/**
 * Check if a session is under the frequency cap for an ad group.
 * Returns true if the user can see more ads from this group.
 */
export async function isUnderFrequencyCap(
	sessionId: string,
	adGroupId: string,
	cap: number | null
): Promise<boolean> {
	if (cap === null) return true // No cap configured

	const impressions = await getSessionImpressions(sessionId, adGroupId)
	return impressions < cap
}
Frequency Cap Performance

The naive approach above queries the database for every ad group candidate. With 10 candidates, that is 10 queries. In production, batch these into a single query that returns impression counts for all candidate ad groups at once, or cache recent counts in Redis/memory.


Weighted Random Selection

After all filters have narrowed the candidates, the final step selects one creative. The selection is weighted, creatives with higher weight values are proportionally more likely to be chosen:

// src/lib/server/ads/selection.ts

import type { Creative } from '$lib/types/ads'

/**
 * Select a creative using weighted random selection.
 *
 * Given creatives with weights [60, 30, 10], the probability
 * of selecting each is [60%, 30%, 10%].
 *
 * Algorithm:
 * 1. Sum all weights → total
 * 2. Generate random number in [0, total)
 * 3. Walk through creatives, subtracting each weight
 * 4. When the random number drops below zero, that's the winner
 */
export function selectCreative(creatives: Creative[]): Creative | null {
	if (creatives.length === 0) return null
	if (creatives.length === 1) return creatives[0]

	const totalWeight = creatives.reduce((sum, c) => sum + c.weight, 0)

	if (totalWeight === 0) {
		// All weights are zero - fall back to uniform random
		return creatives[Math.floor(Math.random() * creatives.length)]
	}

	let random = Math.random() * totalWeight

	for (const creative of creatives) {
		random -= creative.weight
		if (random <= 0) return creative
	}

	// Fallback (shouldn't reach here due to floating point)
	return creatives[creatives.length - 1]
}

To visualize how weighted selection works:

Loading diagram...

If the random number lands between 0 and 60, Hero A wins. Between 60 and 90, Hero B wins. Between 90 and 100, the Text Ad wins.


The Ad Resolver

Now we assemble all the pieces into the resolver, the main entry point that takes a context and returns a decision:

// src/lib/server/ads/resolver.ts

import { db } from '$lib/server/db/client'
import { campaigns, adGroups, creatives } from '$lib/server/db/schema'
import { eq, and } from 'drizzle-orm'
import type { AdRequestContext, Creative, Campaign, AdGroup } from '$lib/types/ads'
import { matchesTargeting } from './targeting'
import { isWithinBudget } from './budget'
import { isUnderFrequencyCap } from './frequency'
import { selectCreative } from './selection'

export interface AdDecision {
	creative: Creative
	campaign: Campaign
	adGroup: AdGroup
	trackingId: string
}

/**
 * Resolve the best ad for a given context.
 * Returns null if no eligible ads are found.
 */
export async function resolveAd(context: AdRequestContext): Promise<AdDecision | null> {
	const startTime = performance.now()

	// ─── Stage 1: Query active campaigns and their ad groups ───
	const activeCampaigns = await db.query.campaigns.findMany({
		where: eq(campaigns.status, 'active'),
		with: {
			adGroups: {
				where: eq(adGroups.isActive, true),
				with: {
					creatives: {
						where: eq(creatives.isActive, true)
					}
				}
			}
		}
	})

	// ─── Stage 2: Apply targeting filters ─────────────────────
	const targetedGroups: Array<{
		campaign: Campaign
		adGroup: AdGroup
		creatives: Creative[]
	}> = []

	for (const campaign of activeCampaigns) {
		// Check campaign date range
		if (context.now < campaign.startDate) continue
		if (campaign.endDate && context.now > campaign.endDate) continue

		for (const adGroup of campaign.adGroups) {
			if (!matchesTargeting(adGroup, context)) continue

			const activeCreatives = adGroup.creatives.filter((c) => c.isActive)
			if (activeCreatives.length === 0) continue

			targetedGroups.push({
				campaign,
				adGroup,
				creatives: activeCreatives
			})
		}
	}

	if (targetedGroups.length === 0) return null

	// ─── Stage 3: Apply budget filters ────────────────────────
	const withinBudget = targetedGroups.filter((group) => isWithinBudget(group.campaign))

	if (withinBudget.length === 0) return null

	// ─── Stage 4: Apply frequency caps ────────────────────────
	const underCap: typeof withinBudget = []

	for (const group of withinBudget) {
		const cap = group.adGroup.targeting.frequencyCap
		const allowed = await isUnderFrequencyCap(context.sessionId, group.adGroup.id, cap)
		if (allowed) underCap.push(group)
	}

	if (underCap.length === 0) return null

	// ─── Stage 5: Select by priority, then by weight ──────────
	// Sort by priority (highest first)
	underCap.sort((a, b) => b.adGroup.priority - a.adGroup.priority)

	// Take the highest-priority group(s) - could be ties
	const highestPriority = underCap[0].adGroup.priority
	const topGroups = underCap.filter((g) => g.adGroup.priority === highestPriority)

	// Collect all creatives from the top-priority groups
	const allCreatives = topGroups.flatMap((g) => g.creatives)

	// Weighted selection among all candidates
	const winner = selectCreative(allCreatives)

	if (!winner) return null

	// Find which group the winning creative belongs to
	const winningGroup = topGroups.find((g) => g.creatives.some((c) => c.id === winner.id))!

	const elapsed = performance.now() - startTime
	if (elapsed > 10) {
		console.warn(`Ad resolution took ${elapsed.toFixed(1)}ms (target: <10ms)`)
	}

	return {
		creative: winner,
		campaign: winningGroup.campaign,
		adGroup: winningGroup.adGroup,
		trackingId: crypto.randomUUID()
	}
}

The resolver is deliberately structured as a sequential funnel rather than a parallel evaluation. Each stage eliminates candidates before passing the remaining set to the next filter, which means expensive operations - the frequency cap database queries in Stage 4 - only run against groups that have already survived cheaper checks.

A campaign that has passed its end date is eliminated in Stage 2 with a single date comparison and it never reaches the budget query or the frequency lookup. This ordering matters most when you have many active campaigns and the database is under load.

The priority-then-weight selection in Stage 5 is also a deliberate choice. Priority gives campaign managers a hard guarantee - a premium sponsor can always win over standard inventory regardless of weight - while weight distributes traffic probabilistically within a priority tier.

If you need strict determinism (same user always sees the same creative), replace the weighted selection with the hash-based assignment from the A/B testing article in Part 5. If you need revenue optimisation, replace it with a bid-price sort. The pipeline stages are stable, only the final selection step needs to change.


The Ad Serving Endpoint

The resolver is exposed through a server endpoint that can be called from load functions or client-side:

// src/routes/api/ads/serve/+server.ts

import { json } from '@sveltejs/kit'
import type { RequestHandler } from './$types'
import { resolveAd } from '$lib/server/ads/resolver'
import { extractAdContext } from '$lib/server/ads/context'

export const GET: RequestHandler = async (event) => {
	const placement = event.url.searchParams.get('placement')

	if (!placement) {
		return json({ error: 'Missing placement parameter' }, { status: 400 })
	}

	const context = extractAdContext(event, placement)
	const decision = await resolveAd(context)

	if (!decision) {
		return json({ ad: null })
	}

	const { creative, trackingId } = decision

	return json({
		ad: {
			id: trackingId,
			creativeId: creative.id,
			format: creative.format,
			content: creative.content,
			clickUrl: `/api/ads/track?type=click&id=${trackingId}&redirect=${encodeURIComponent(creative.clickUrl)}`,
			impressionUrl: `/api/ads/track?type=impression&id=${trackingId}`
		}
	})
}

The AdSlot Component

The AdSlot component is the client-facing piece. Two Svelte 5 features shape its design in ways worth understanding before reading the code.

The IntersectionObserver lifecycle is managed with {@attach} rather than onMount. Attachments are the Svelte 5.29+ preferred alternative to DOM-lifecycle work that was previously done in onMount. They receive the element directly, run reactively when their dependencies change, and clean up automatically. The key advantage here: when the ad prop changes (a new creative replaces the old one), the attachment reruns automatically, creating a fresh IntersectionObserver and resetting the impression-tracked flag - which is exactly the behaviour needed when an ad rotates. With onMount, this reset would require explicit $state and a separate $effect watching the ad prop.

The creative rendering is wrapped in <svelte:boundary> with a failed snippet. Ad creatives arrive from third parties and their content can be malformed - an HTML creative with unexpected structure, a video with an unsupported codec reference, or a runtime error in a snippet rendered from SSE data. Without a boundary, any of these would unmount the entire page. With a boundary, the error is contained and the rest of the page continues to function.

<!-- src/lib/components/ads/AdSlot.svelte -->

<script lang="ts">
	import type { CreativeContent } from '$lib/types/ads'

	interface Props {
		/** The placement slug */
		placement: string
		/** Pre-resolved ad data from server (if available via load function) */
		ad?: {
			id: string
			creativeId: string
			format: string
			content: CreativeContent
			clickUrl: string
			impressionUrl: string
		} | null
		/** Fallback content when no ad is available */
		fallback?: import('svelte').Snippet
		/** CSS class for the container */
		class?: string
	}

	let { placement, ad = null, fallback, class: className = '' }: Props = $props()
</script>

<!--
  {@attach} replaces onMount + bind:this for the IntersectionObserver.
  When the `ad` prop changes, the attachment reruns automatically - creating
  a fresh observer and resetting the local `tracked` flag so a new impression
  is recorded for the incoming creative. With onMount this would require a
  $state flag plus a separate $effect watching the ad prop.
-->
<div
	class="ad-slot ad-slot-{placement} {className}"
	data-placement={placement}
	{@attach (node) => {
		if (!ad) return

		// Local to this attachment instance - resets when ad changes
		let tracked = false

		const observer = new IntersectionObserver(
			(entries) => {
				for (const entry of entries) {
					if (entry.isIntersecting && !tracked) {
						tracked = true
						navigator.sendBeacon(ad.impressionUrl)
						observer.disconnect()
					}
				}
			},
			{ threshold: 0.5 }
		)

		observer.observe(node)
		return () => observer.disconnect()
	}}
>
	<!--
	  <svelte:boundary> isolates creative rendering from the rest of the page.
	  Ad content arrives from third parties and can be malformed - a broken HTML
	  creative, an unexpected content shape, or a runtime error in the renderer.
	  Without a boundary the whole page unmounts; with one, only the ad slot fails.
	-->
	<svelte:boundary>
		{#if ad}
			<a
				href={ad.clickUrl}
				class="ad-creative"
				target="_blank"
				rel="noopener sponsored"
				data-ad-id={ad.id}
			>
				{#if ad.content.type === 'image'}
					<img
						src={ad.content.src}
						alt={ad.content.alt}
						width={ad.content.width}
						height={ad.content.height}
						loading="lazy"
					/>
				{:else if ad.content.type === 'text'}
					<div class="text-ad">
						<strong class="text-ad-headline">{ad.content.headline}</strong>
						<p class="text-ad-body">{ad.content.body}</p>
						<span class="text-ad-cta">{ad.content.ctaText}</span>
					</div>
				{:else if ad.content.type === 'html'}
					<!-- Sandboxed HTML creative -->
					<iframe
						srcdoc={ad.content.markup}
						sandbox="allow-popups allow-popups-to-escape-sandbox"
						title="Advertisement"
						class="html-ad-frame"
						loading="lazy"
					></iframe>
				{:else if ad.content.type === 'video'}
					<video
						src={ad.content.src}
						poster={ad.content.poster}
						muted
						autoplay
						playsinline
						loop
						class="video-ad"
					>
						<track kind="captions" />
					</video>
				{/if}

				<span class="ad-label">Ad</span>
			</a>
		{:else if fallback}
			{@render fallback()}
		{/if}

		{#snippet failed()}
			<!-- Creative rendering failed - show nothing rather than crashing the page -->
		{/snippet}
	</svelte:boundary>
</div>

<style>
	.ad-slot {
		position: relative;
		overflow: hidden;
	}

	.ad-creative {
		display: block;
		text-decoration: none;
		color: inherit;
		position: relative;
	}

	.ad-creative img {
		width: 100%;
		height: auto;
		display: block;
	}

	.text-ad {
		padding: 1rem;
		border: 1px solid var(--border-color, #e5e7eb);
		border-radius: 0.5rem;
		background: var(--surface-1, #fff);
	}

	.text-ad-headline {
		display: block;
		font-size: 1.1rem;
		margin-bottom: 0.25rem;
	}

	.text-ad-body {
		font-size: 0.9rem;
		color: var(--text-muted, #6b7280);
		margin: 0.5rem 0;
	}

	.text-ad-cta {
		display: inline-block;
		padding: 0.35rem 0.75rem;
		background: var(--accent-blue-base, #3b82f6);
		color: white;
		border-radius: 0.25rem;
		font-size: 0.85rem;
		font-weight: 500;
	}

	.html-ad-frame {
		width: 100%;
		border: none;
		overflow: hidden;
	}

	.video-ad {
		width: 100%;
		height: auto;
		display: block;
	}

	.ad-label {
		position: absolute;
		top: 0.25rem;
		right: 0.25rem;
		background: rgba(0, 0, 0, 0.6);
		color: white;
		font-size: 0.65rem;
		padding: 0.1rem 0.35rem;
		border-radius: 0.15rem;
		text-transform: uppercase;
		letter-spacing: 0.05em;
	}
</style>
Impression Tracking with IntersectionObserver

We use IntersectionObserver to track impressions only when the ad is actually visible to the user - at least 50% in the viewport, which matches the IAB viewability standard. The observer is set up inside {@attach} rather than onMount, which means it is recreated automatically when the ad prop changes. Each attachment instance gets its own tracked local variable, so a new creative always starts with a clean impression count regardless of what the previous creative did.


Integrating with Load Functions

The preferred way to serve ads in SvelteKit is through load functions. This ensures ads are included in the server-rendered HTML:

// src/routes/(app)/articles/[slug]/+page.server.ts

import type { PageServerLoad } from './$types'
import { resolveAd } from '$lib/server/ads/resolver'
import { extractAdContext } from '$lib/server/ads/context'

export const load: PageServerLoad = async (event) => {
	const { params } = event

	// Fetch article data (your existing logic)
	const article = await getArticle(params.slug)

	// Resolve ads for placements on this page
	const [sidebarAd, footerAd] = await Promise.all([
		resolveAd(extractAdContext(event, 'sidebar-top')),
		resolveAd(extractAdContext(event, 'article-footer'))
	])

	return {
		article,
		ads: {
			sidebar: sidebarAd
				? {
						id: sidebarAd.trackingId,
						creativeId: sidebarAd.creative.id,
						format: sidebarAd.creative.format,
						content: sidebarAd.creative.content,
						clickUrl: `/api/ads/track?type=click&id=${sidebarAd.trackingId}&redirect=${encodeURIComponent(sidebarAd.creative.clickUrl)}`,
						impressionUrl: `/api/ads/track?type=impression&id=${sidebarAd.trackingId}`
					}
				: null,
			footer: footerAd
				? {
						id: footerAd.trackingId,
						creativeId: footerAd.creative.id,
						format: footerAd.creative.format,
						content: footerAd.creative.content,
						clickUrl: `/api/ads/track?type=click&id=${footerAd.trackingId}&redirect=${encodeURIComponent(footerAd.creative.clickUrl)}`,
						impressionUrl: `/api/ads/track?type=impression&id=${footerAd.trackingId}`
					}
				: null
		}
	}
}

Then in the page component:

<!-- src/routes/(app)/articles/[slug]/+page.svelte -->

<script lang="ts">
	import type { PageProps } from './$types'
	import AdSlot from '$lib/components/ads/AdSlot.svelte'

	let { data }: PageProps = $props()
</script>

<div class="article-layout">
	<main class="article-content">
		<!-- Article content here -->
	</main>

	<aside class="sidebar">
		<AdSlot placement="sidebar-top" ad={data.ads.sidebar}>
			{#snippet fallback()}
				<div class="house-ad">Subscribe to our newsletter!</div>
			{/snippet}
		</AdSlot>
	</aside>

	<footer class="article-footer">
		<AdSlot placement="article-footer" ad={data.ads.footer} />
	</footer>
</div>

Performance: Caching the Resolution

Querying the database on every page load is wasteful when campaign data changes infrequently. To optimize performance, we add a simple in-memory cache:

// src/lib/server/ads/cache.ts

import type { Campaign, AdGroup, Creative } from '$lib/types/ads'

interface CachedData {
	campaigns: Array<
		Campaign & {
			adGroups: Array<AdGroup & { creatives: Creative[] }>
		}
	>
	timestamp: number
}

let cache: CachedData | null = null
const CACHE_TTL = 60_000 // 1 minute

export function getCachedCampaigns(): CachedData['campaigns'] | null {
	if (!cache) return null
	if (Date.now() - cache.timestamp > CACHE_TTL) {
		cache = null
		return null
	}
	return cache.campaigns
}

export function setCachedCampaigns(data: CachedData['campaigns']): void {
	cache = { campaigns: data, timestamp: Date.now() }
}

/**
 * Invalidate the cache.
 * Call this after any campaign/ad group/creative mutation.
 */
export function invalidateAdCache(): void {
	cache = null
}

This cache stores the active campaigns and their ad groups/creatives for 1 minute. The resolver checks the cache first before querying the database. Whenever a campaign, ad group, or creative is created/updated/deleted through the admin interface, we call invalidateAdCache() to ensure the resolver gets fresh data on the next request.

Update the resolver to use the cache:

// In resolver.ts - replace the database query in Stage 1:

import { getCachedCampaigns, setCachedCampaigns } from './cache'

// Stage 1: Query or use cache
let activeCampaigns = getCachedCampaigns()

if (!activeCampaigns) {
	activeCampaigns = await db.query.campaigns.findMany({
		where: eq(campaigns.status, 'active'),
		with: {
			adGroups: {
				where: eq(adGroups.isActive, true),
				with: {
					creatives: {
						where: eq(creatives.isActive, true)
					}
				}
			}
		}
	})
	setCachedCampaigns(activeCampaigns)
}

This cache reduces the resolver’s hot path from a 50–100ms database round-trip down to a sub-millisecond map lookup for the vast majority of requests. Campaign data changes infrequently relative to how often the resolver runs - a campaign configured at 9am will serve thousands of requests before its first edit. A one-minute TTL accepts at most 60 seconds of stale data in exchange for eliminating the database entirely from the hot path.

The limitation of an in-process cache is that it lives in the Node.js process. On a single-server deployment this is fine. On a multi-instance deployment - Vercel serverless functions, a horizontally scaled container cluster - each instance has its own cache, meaning a campaign update triggers a cache miss on the instance that handled the update but leaves all other instances serving stale data until their TTL expires.

This is almost always acceptable for a one-minute window, but if you need stronger consistency, let say, a paused campaign that must stop serving immediately across all instances, you have two options: drop the TTL to a few seconds (accepting more database traffic) or move to a shared cache like Redis where a single invalidateAdCache() call reaches every instance at once. Part 7 covers the Redis implementation.

Cache Invalidation

Call invalidateAdCache() in every form action that modifies campaigns, ad groups, or creatives. This ensures the resolver always uses fresh data after edits, while still caching during normal page loads.


What’s Next

The ad rendering engine is now complete:

  • Context extraction from SvelteKit requests (device, geo, session)
  • Targeting filters for placement, device, geography, schedule, and URL
  • Budget checking against daily and lifetime limits
  • Frequency capping per session/user per ad group
  • Weighted selection among remaining candidates
  • AdSlot component with IntersectionObserver-based impression tracking
  • Load function integration for server-rendered ads
  • In-memory caching for the hot path