Back to blog

Shipping an Ads Unit from Concept to Production

the thing nobody tells you about ads

so we shipped an ads unit at Upwork recently. "ads unit" sounds simple — show a sponsored listing, track some clicks, collect revenue. i figured the hard part would be the frontend. rendering ads in the right spots, making them look native, handling responsive layouts. turns out that was maybe 20% of the actual work.

the other 80% was analytics. and coordination. mostly coordination.

how it started

product came to us with a pretty clear vision: let employers promote their job posts so they show up higher in search results and on freelancer dashboards. the pitch was straightforward — we already have the job data, we already have the placements, we just need to wire up a way to serve promoted listings and track performance.

"just" doing a lot of heavy lifting in that sentence.

we broke it into three workstreams. frontend would handle the ad component and placement logic. backend would build the ad serving API and targeting. analytics would own impression tracking, click-through rates, and revenue attribution. three teams, one feature, what could go wrong.

the frontend was the easy part

the actual Vue component for rendering an ad ended up being surprisingly clean:

<template>
  <div v-if="ad" class="ad-unit" :data-placement="placement">
    <a :href="ad.clickUrl" @click="trackClick" target="_blank" rel="sponsored">
      <img :src="ad.imageUrl" :alt="ad.title" />
      <span class="ad-label">Sponsored</span>
    </a>
  </div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import type { AdPlacement, AdResponse } from '@/types/ads'
import { useAdTracking } from '@/composables/useAdTracking'
 
const props = defineProps<{
  ad: AdResponse | null
  placement: AdPlacement
}>()
 
const { trackImpression, trackClick } = useAdTracking(props.placement)
 
onMounted(() => {
  if (props.ad) {
    trackImpression(props.ad.id)
  }
})
</script>

that's it. a conditional render, a click handler, an impression tracker on mount. we had A/B testing variants too — different label styles, image sizes, with and without descriptions — but those were just prop variations on the same base component. the frontend was done in like a week and a half.

the backend was medium hard

the ad serving API needed to handle a few things: which ads are eligible for a given placement, what targeting criteria match the current user, and how to rank competing ads when multiple employers are bidding for the same slot. we ended up with a scoring function that weighted relevance, bid amount, and historical CTR.

the targeting logic was more nuanced than i expected. you can't just show any promoted job to any freelancer. the job needs to match their skills, their location preferences, their availability. so the serving endpoint was basically running a mini search query every time someone loaded a page. we cached aggressively but it still added latency we had to optimize around.

analytics ate the timeline

here's what surprised me the most. the analytics requirements were genuinely more complex than building the ad rendering and serving combined. product needed:

  • impression tracking with viewability thresholds (the ad has to be 50% visible for at least one second to count)
  • click-through rates broken down by placement, device type, and user segment
  • conversion tracking — did the freelancer who saw the promoted job actually apply to it?
  • revenue attribution — when an employer pays for a promotion and gets a hire, how much of that outcome do we attribute to the ad?

the viewability thing alone was a rabbit hole. you need an intersection observer watching every ad unit, a timer that starts when it crosses the threshold, and deduplication logic so you don't count the same impression twice if someone scrolls past and back. we built a composable for it but it went through four iterations before analytics was happy with the data quality.

conversion tracking meant we needed to stitch together events across sessions. a freelancer sees a promoted job on monday, comes back wednesday and applies. connecting those two events required a whole attribution pipeline that the analytics team built on the backend. frontend just had to make sure we were passing the right identifiers through every interaction.

what shipped vs what was planned

the original timeline was six weeks. we shipped in ten. the frontend was done on schedule. the backend was about a week late because of the caching work. analytics took an extra three weeks because every time we thought we had the tracking right, someone would find an edge case — ads in infinite scroll lists, ads that load lazily below the fold, users who open ads in new tabs.

we also cut some features. the original spec had real-time bidding where employers could adjust bids based on live performance. that got pushed to v2. we shipped with fixed-price promotions instead, which was way simpler and honestly covered 90% of the use case.

the coordination tax

the thing i keep coming back to is how much time went into just getting everyone aligned. frontend needed the API contract before we could build. backend needed the tracking requirements before they could design the data model. analytics needed both to be done before they could validate anything. every dependency was a potential blocker.

we ended up doing daily standups across all three teams for the last four weeks. that sounds like a lot of meetings but it actually saved time because we caught misalignments early instead of discovering them during integration.

if i had to do it again i'd start with the analytics requirements. not the frontend, not the API. figure out exactly what you need to measure first, then design everything else to support those measurements. the tracking requirements shaped the API response format, the component lifecycle hooks, even the caching strategy. analytics was the real architecture driver and we didn't realize that until halfway through.

ads are live now. CTRs are healthy. revenue is growing. and i learned that the boring coordination work is usually where the real engineering challenge lives.