Code Agency
19 min readBy Fabio Tielen

Your traffic didn't drop, your measurement did: analytics after the consent banner

Sessions fell 41% the month after the relaunch and the leads went up, because the old site counted everybody and the new one only counts the people who said yes. What the consent rule actually covers, why cookieless is not automatically banner-free, the server-side denominator that makes the numbers honest again, and why the count that pays lives in the ERP.

Four weeks after a relaunch went live, the marketing lead forwarded us a screenshot with one line above it: "we need to talk about this."

Sessions were down 41% year on year. Users down 44%. Every acquisition channel down, in roughly the same proportion, starting on the exact day of the switchover. The site was demonstrably faster, the redirects were clean, rankings hadn't moved, and Search Console showed impressions and clicks flat to slightly up. None of that mattered in the room, because there was a chart going down and a project to justify.

The old site fired its analytics tag in the <head>, before the cookie banner had finished rendering. The new one doesn't fire it until somebody clicks accept. Nobody's traffic went anywhere. Roughly half the visitors simply stopped being counted, and the half that vanished had never legally been counted in the first place.

The same four weeks had produced 23 leads against the previous year's 17. That number was in the CRM the whole time, and nobody was looking at it, because "sessions" is the number everybody has always looked at.

This post is about what you're actually allowed to measure, what that does to your numbers, and where to get the truth instead. We're engineers rather than your DPO, and none of this is legal advice — but the architectural consequences are ours to build, and most of the pain we see comes from teams treating a legal constraint as a plugin setting.

The rule is about the device, not the data

Almost every argument we have about this starts from the same wrong premise: that the question is GDPR, and therefore that the question is personal data. It isn't, and getting this backwards is what produces the confident-and-wrong statement "we anonymise the IP, so we don't need consent."

There are two separate rules stacked on top of each other.

ePrivacy — article 5(3), transposed in Belgium through the Electronic Communications Act. Storing information on a visitor's device, or reading information already stored there, requires their consent. There are exactly two exemptions: carrying out the transmission, and being strictly necessary for a service the user explicitly requested. Note what is not in that sentence — no mention of personal data, no mention of anonymisation, and no legitimate-interest option. A perfectly anonymous cookie still needs consent. So does localStorage. So does anything else you leave behind on the machine.

GDPR. On top of that, whatever personal data you then process needs a lawful basis, a retention period, a processor agreement with whoever runs the tool, and a story about transfers if the data leaves the EU.

The practical consequence is that your analytics vendor's privacy page answers the second question and quietly skips the first. "We don't use personal data" is a GDPR claim. It has no bearing on whether you're allowed to write a visitor id into someone's browser.

Two more things worth knowing, because they come up in every one of these conversations:

  • Legitimate interest is not available for the storage step. The EDPB's cookie banner taskforce report said this plainly, and it's the single most common mistake we find in an existing implementation — an analytics category defaulted to on, justified by legitimate interest, which is a GDPR concept being applied to an ePrivacy problem.
  • Cookieless is not automatically banner-free. The EDPB's guidelines on the technical scope of article 5(3) extended it well past cookies — to pixels, to URL-based tracking, to local processing that later reports back, and in some configurations to IP-based identification. "We removed the cookies" is the start of the analysis, not the end of it. It also does nothing about the GDPR layer, which still applies to the processing.

France's CNIL publishes an exemption regime for tightly-scoped first-party audience measurement, and clients occasionally quote it at us. It's real, and it comes with conditions most tools don't meet by default. Belgium's DPA has published no equivalent carve-out, and the Belgian regulator has form here — its 2022 decision against IAB Europe's consent framework put a good deal of the ad industry's plumbing in question. For a Belgian business we plan on the assumption that analytics storage needs consent, and we're pleasantly surprised if a DPO argues otherwise.

What that costs you, in numbers

Acceptance rates vary far more than any benchmark suggests, and the biggest variable is the banner rather than the audience. From the sites we run and the ones we've inherited, the honest range for a plainly-designed, non-manipulative banner is 45–70% of visitors accepting measurement. B2B skews lower than consumer. Traffic from an ad campaign skews lower than direct traffic from someone who already knows you. Mobile is a few points lower than desktop.

You can push that number up, and there is an industry of "consent optimisation" built on doing exactly that. Most of it is either a dark pattern or one step away from becoming one, and dark patterns in banners are precisely what regulators have spent the past four years enforcing against. We don't build them, and we'd rather explain a lower number than defend a design decision to a DPA.

What matters more than the rate itself is that it applies unevenly, which is what makes the resulting data quietly misleading rather than merely smaller:

SegmentEffect on the measured picture
Privacy-conscious / technical visitorsUnder-represented — the developer audience rejects at a much higher rate
Ad-driven trafficUnder-represented — cold visitors reject more than returning ones
Ad-blocked visitorsMissing entirely, banner or not, and blockers are common in a technical audience
Returning visitorsOver-represented — they've clicked accept before and it stuck
Bots and crawlersNever in the analytics at all, but very much in your server logs

So consent doesn't scale your numbers down by a constant. It tilts them. Bounce rate, channel mix and device split all shift, and every one of those shifts looks like a real behavioural change if you don't know it happened.

The denominator you're allowed to have

Here's the part that fixes most of the argument, and it's cheap: you already count every request, and doing so requires no consent at all.

An ingress log, a CDN log or a reverse-proxy metric is a record of something the server did. Nothing was written to anyone's device and nothing was read from it, so article 5(3) doesn't apply. You still owe the GDPR layer — an IP is personal data, so don't retain raw IPs longer than you need for operations and security — but the aggregate counts you derive are yours to keep.

We already run metrics and alerting for everything else we host, so the marginal cost of a traffic panel is one dashboard. The query is unglamorous:

# Human-ish page views per path, from the ingress — no consent required,
# because none of this touches the visitor's device.
sum by (path) (
  rate(nginx_ingress_controller_requests{
    host="www.example.be",
    status=~"2..",
    path!~"/_next/.*|/api/.*|.*\\.(svg|png|jpg|webp|css|js|ico|xml|txt)"
  }[24h])
)

Three warnings, because this number is only useful if you treat it honestly:

It counts requests, not people. There is no session and no visitor id, by design. Compare it to itself over time; don't try to make it into "users".

Bots are a large share of it. On a small business site, crawlers and scanners are routinely a third to half of all requests, and unlike your analytics tool nothing is filtering them. Exclude the obvious ones by user agent, exclude your own uptime checks and preview environments, and expect the residue to still be noisy.

Caching hides some of it. Static pages served from a CDN edge never reach your origin, so origin metrics undercount. Use the CDN's own log stream where you have one, and be consistent about which layer you're reading.

What this buys you is the ratio. Measure it once — consented sessions over total non-asset page views — for a full month, and you have a factor for the traffic you can't see. Ours run between 0.4 and 0.6 depending on the client's audience. It is not precise. It is enormously better than reporting a 41% drop that didn't happen.

Two systems, and never one chart

The relaunch fight would not have happened if one person had drawn a vertical line on the graph.

There is a hard rule that comes out of all of this and it costs nothing to follow: a series that spans a measurement change is not a series. The day the banner changed, the day the tag moved out of the <head>, the day you switched tools, the day you added a second domain — each of those is a discontinuity, and putting data from both sides of it in one line chart produces a claim that is simply false.

What we do instead, and put in the handover document so it survives the person who built it:

  • Annotate the change on the dashboard, in the dashboard, not in an email that gets deleted. Every serious tool has annotations; almost nobody uses them.
  • Restate the baseline. Post-change month one is the new zero. Year-on-year comparisons across a consent change are dead for a year, and pretending otherwise wastes more time than saying so.
  • Report two numbers, always. "Measured sessions: 4,180. Estimated total, using this quarter's consent ratio of 0.52: ~8,000." Anyone can argue with the estimate. Nobody can argue that reporting only the first one is complete.

The count that pays doesn't come from analytics

The relaunch story has a happy ending because the client sells services rather than page views, and every enquiry they've ever received exists as a record in Odoo. Twenty-three leads is twenty-three leads. It doesn't have a consent rate, it isn't sampled, and no ad blocker has ever removed one.

This is the shift we push hardest with clients, and it's the one that survives whatever the next five years of privacy regulation does: stop measuring the funnel and start counting the outcome. A business record created as part of a transaction the visitor deliberately initiated is not analytics. Its lawful basis is the pre-contractual step the person asked you to take. It is complete by construction.

For the sites we build, that means the contact form writes straight into the CRM — a lead with the service they picked, the message, and the small amount of context the request itself carried:

app/contact/actions.ts — attribution the request already gave you
"use server"
 
// Nothing here is read from or written to the visitor's device. The referrer
// and the campaign parameters arrive with the submission; we copy them onto
// the record we were going to create anyway.
const source = {
  landingPage: formData.get("page")?.toString() ?? "/contact",
  referrer: formData.get("ref")?.toString() || "(direct)",
  campaign: formData.get("utm_campaign")?.toString() || undefined,
}
 
await createLead({
  name,
  email,
  service,
  message,
  // Free-text note on the lead, not a profile and not a cross-site identity.
  description: `${message}\n\n— from ${source.landingPage} via ${source.referrer}`,
  mediumId: MEDIUM_DIRECT,
  sourceId: SOURCE_WEBSITE,
})

Two honest limitations, stated up front rather than discovered later:

This is last-touch, and only from the page the form lives on. Carrying a first-touch campaign across a week of visits requires persisting an identifier on the device, which is the exact thing that needs consent. You can do it inside the consent bucket, for consenting visitors, and then you have first-touch attribution for half your leads and nothing for the other half — which is usually worse than a consistent last-touch number for all of them.

Multi-touch attribution is not coming back. For a B2B company doing twenty to fifty enquiries a month, it was never worth what it cost. The useful questions — how many leads, from which service page, at what quality, closing at what rate — are all answerable from the CRM, and lead scoring in Odoo does more for the sales conversation than an attribution model ever did.

The same logic runs through the rest of the stack. Newsletter performance comes from the sending platform, where opens and clicks belong to a subscriber who opted in. Phone enquiries come from the PBX. Support load comes from the helpdesk. None of it depends on a banner, because none of it is measurement in the ePrivacy sense — it's the record of something that happened.

The banner is a component, and it's usually the worst one on the site

Once you accept that the banner decides half your data and touches 100% of your visitors, it stops being a compliance checkbox and becomes a piece of UI with a spec.

Reject has to be as easy as accept. One click, first layer, same visual weight. This is the requirement regulators have been most consistent about, and "Accept all" in a filled yellow button next to "Manage preferences" in grey 12px text is the exact pattern they were describing. Beyond the legal exposure, a rejected visitor who found the button in one click is a better outcome than a confused visitor who accepted by accident — the second one is data you can't rely on and a person you've annoyed.

Nothing pre-ticked, and nothing fires before the click. We've audited sites where the banner was immaculate and the tag manager loaded three vendors on page load regardless. The banner is theatre if the network tab disagrees with it. Check the network tab. It's a five-minute audit and it fails more often than it passes.

Reserve its space. A banner that appears after first paint and pushes the page down is a layout shift on every single visit, which is a Core Web Vitals problem on the one metric that's hardest to fix later. Fix it the boring way: give it a fixed footprint from the first render.

Make it keyboard-navigable and announce it. It's a modal, often a focus trap, and it's the first thing between a keyboard or screen-reader user and your content. The same discipline we apply to navigation that doesn't trap keyboards applies here, with higher stakes, because this one is unskippable.

Store the decision first-party and honour it everywhere. The consent record itself is strictly necessary — you're allowed to remember that someone said no. Keep it in your own domain, keep a timestamp and the version of the policy they agreed to, and make withdrawal as reachable as the original prompt.

The wiring on our own site is deliberately dull. The analytics library is not imported at module scope at all — it's behind a dynamic import inside an effect that watches the consent state, and the manual pageview only starts once the client exists:

components/analytics.tsx — nothing loads before the decision
"use client"
 
export function Analytics() {
  const { consents } = useConsentManager()
  const [ready, setReady] = React.useState(false)
  const consented = Boolean(consents?.measurement)
 
  React.useEffect(() => {
    if (!(consented && POSTHOG_KEY) || ready) {
      return
    }
    // Dynamic import, not a top-level one: otherwise the library is bundled
    // and parsed on every page load even for the visitors who never consent.
    import("posthog-js").then(({ default: posthog }) => {
      posthog.init(POSTHOG_KEY, {
        api_host: POSTHOG_HOST,
        capture_pageview: false, // the library never decides when it may start
        persistence: "localStorage+cookie",
      })
      setReady(true)
    })
  }, [consented, ready])
 
  return null
}

Two things are load-bearing there. The early return — a tool configured to "wait for consent" through its own settings panel is one library upgrade away from deciding differently, while a tool that is never called cannot fire. And the dynamic import, which took roughly 50 KB gzipped off the critical path for the majority of visitors who decline, and which we only noticed because the bundle was being measured for other reasons.

Anything in the marketing category gets the same treatment, one level stricter. We run a B2B visitor-identification tool ourselves, and it's a good illustration of the problem: it identifies the company behind an IP rather than a person, which sounds like it sidesteps everything, and it still sits behind marketing consent with its account id declared inside the effect — because loading a third-party tracking script is a decision the visitor gets to make, whatever the vendor's FAQ says.

Self-hosting solves the other half of the problem

Clients who reach the end of this conversation usually ask whether running the analytics themselves makes the banner go away. It doesn't — the storage rule follows the tool, not the hosting — and any vendor implying otherwise is selling you a compliance story rather than a product.

What it does solve is everything on the GDPR side of the stack, which is the half that generates the paperwork:

  • Transfers. Data that never leaves an EU cluster doesn't need a transfer assessment. This is the cleanest argument in the whole area, and it's the one a DPO cares about most.
  • Processor sprawl. Every SaaS tool is another agreement, another sub-processor list to re-read, another entry in the record of processing. Self-hosted, that's one line.
  • Retention. You set it, in a config file, and it happens. On most platforms you get the retention the plan gives you.
  • Purpose. Your traffic data isn't in the same database as an advertising graph. For most clients this is the reassurance that actually lands.
  • Blocking. First-party endpoints on your own domain aren't in the standard blocklists, so the data you are allowed to collect is more complete.

The trade is that you now operate it. An analytics database is write-heavy, grows without bound and is exactly the sort of service that becomes someone's weekend when nobody owns it — so it belongs on the same hosting footing as everything else we run, with backups, alerts and a named owner, or it belongs on somebody else's infrastructure. What we won't do is run it as a hobby service in the corner of a cluster and call it strategy.

What we ship by default

For a business site with no advertising spend — which is most of them — the default is smaller than people expect:

  1. Static-first pages so that server-side counts are meaningful and fast to serve.
  2. A traffic panel from ingress or CDN metrics, no consent required, running from day one. This is the denominator.
  3. No third-party scripts at all before consent, verified in the network tab, not in the settings UI.
  4. One measurement category behind a plain banner with a first-layer reject. Marketing tags only exist if the client actually runs ads.
  5. Business events from the systems of record — leads and tickets in Odoo, sends in the mailing platform, calls in the PBX.
  6. A dashboard with the annotations on it, and a monthly report that states the consent ratio next to the measured number.

Clients occasionally ask what it would take to have no banner at all, and it's a real option we've taken more than once: no analytics storage, no marketing tags, server-side counts plus CRM outcomes, and a cookie policy that says so. You lose per-visitor journeys. You gain a site that measures 100% of its visitors instead of half of them, one fewer modal in front of the content, and nothing to argue about. For a company whose website exists to produce twenty enquiries a month, that trade is not close.

Where we push back

"Can't we just leave the tag in the head like the old site?" You can, and plenty of sites do. It is a straightforward breach of a rule that both the Belgian DPA and its neighbours have been actively enforcing, and it makes every number you report a number you'd have to disown later. It's also the reason the drop looks so dramatic when someone finally fixes it — the longer you wait, the bigger the cliff.

"Make reject a bit harder to find, just slightly." No. This is the specific behaviour under enforcement, and the upside is a few percentage points on a metric that isn't the one your business runs on.

"Consent Mode will model the missing conversions." It will model them, and modelling is not measuring. It's a reasonable input for bidding if you're spending real money on ads. It's not a source of truth for a board slide, and it doesn't remove any of the obligations above.

"Our previous agency's cookie tool handles all of this." Maybe. Open the network tab on a fresh browser profile, reject everything, and look at what still loads. We have never once run that check on an inherited site and found nothing.

"Then what do I tell the board about the drop?" That the site was never getting 8,000 sessions a month in the sense they were being reported, that you now have two numbers instead of one — a measured floor and a server-side total — and that the number the business is actually paid on went from 17 to 23. That conversation goes fine, once. Having it every quarter because nobody wrote down when the baseline moved is the part that gets tiring.

The short version

A consent banner doesn't reduce your traffic, it reduces your sample, and it does so unevenly enough that the composition of your data changes too. Treating that as a drop is a reporting error that costs teams real credibility, usually at the worst possible moment — four weeks after a relaunch someone has to justify.

The way out is three things, none of them expensive. Keep a server-side denominator that needs no consent, so you always know the shape of the traffic you can't see. Never draw one line through a measurement change. And move the numbers that matter out of analytics entirely and into the systems of record, where a lead is a lead whether or not anyone clicked accept.

Do that and the banner stops being a threat to your reporting and goes back to being what it is: a modal you should design carefully, load nothing before, and otherwise not think about very much. It's the same instinct behind the way we build the rest of a marketing site and the custom applications behind it — count the things that are true, be explicit about the things you're estimating, and don't build a business case on a number that only ever existed because nobody had got round to asking permission.

Want us to publish something specific?

Tell us what you'd like to read and we'll add it to our writing queue.

Get the next one in your inbox

New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.