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:
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 ProductionMost CDN providers inject geolocation headers into requests. Cloudflare adds
cf-ipcountryandcf-region-code. Vercel addsx-vercel-ip-countryandx-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 PerformanceThe filters are ordered from cheapest to most expensive.
matchesPlacementis a simple arrayincludes()check.matchesSchedulecreates anIntl.DateTimeFormatinstance 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 PerformanceThe 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:
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 IntersectionObserverWe use
IntersectionObserverto 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 thanonMount, which means it is recreated automatically when theadprop changes. Each attachment instance gets its owntrackedlocal 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 InvalidationCall
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