返回资讯中心
外部精选
软件工程
#开发者实时流

We built a cookie banner that makes your scripts start sooner

A returning visitor's consent is already on the server. c15t now uses it to start their allowed tags from the HTML in Next.js, Nuxt, SvelteKit and TanStack Start, before the page hydrates, without slowing the page …

Hacker News 最新burnedchris2026/10/30 · 2 天前30 分钟阅读

以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。

A returning visitor's consent is already on the server. c15t now uses it to start their allowed tags from the HTML in Next.js, Nuxt, SvelteKit and TanStack Start, before the page hydrates, without slowing the page down.

Kaylee WilliamsFounding Engineer · 29 October 2026 · 18 min read

We build the c15t cookie banner, but this change is about the scripts it gates. The server already knows a returning visitor's consent, so it now starts their allowed tags before the page hydrates. On our Next.js bench the first tag starts at 1,073 ms, against 1,773 ms with next/script, which has no consent check. That's about 700 ms sooner. Meta Pixel and TikTok Pixel also load sooner, by 87 ms and 197 ms. The Google tag is the exception. next/script still loads it first, at 1,986 ms against our 2,631, and the Meta and TikTok beacons go out about 170 ms earlier.

c15t only loads a third-party tag after it checks the visitor's consent. In a React app that check lived in the app's JavaScript. So every tag waited for the page to hydrate, even for a returning visitor who had already said yes. On a throttled mobile profile, that visitor's first tag went in 2 seconds after navigation.

The server already reads the consent cookie to render the page, so now it starts those tags too. On the same bench the first tag goes in at 1,073 ms instead of 2,000, about 750 ms before the page hydrates. First and largest contentful paint stay where they were.

It works in Next.js, TanStack Start, Nuxt and SvelteKit, for the vendor helpers in @c15t/integrations. On the Nuxt, SvelteKit and TanStack Start bench apps, the last vendor ran 572 ms to 1,163 ms sooner. Below we cover how it works and how it compares with next/script, @next/third-parties and @nuxt/scripts. We also cover the versions that made the page slower and why a tag that starts 900 ms sooner only finishes about 430 ms sooner.

Before a tag runs, c15t's script loader checks the visitor allowed it, runs the vendor's setup, like gtag('consent', 'default', ...) and then inserts the tag. The loader is part of the app bundle, so in React none of that happens until the bundle has downloaded and hydrated.

For a first visit that's right. Nothing optional should run before the visitor chooses. For a returning visitor it's wasted time. With resolveConsent(), the server reads the visitor's stored choice on every request, before it sends any HTML.

The server already reads the consent cookie to render the page. It can start the tags too.

The browser still runs the consent checks before any tag loads.

Our first idea was to let the framework load the tags. next/script preloads a tag during server rendering and inserts it after hydration, and Nuxt and the others have something similar. Reading Next's script.js ruled it out for consent-gated tags:

Nuxt has @nuxt/scripts, with a trigger that waits for consent. We ran it against the server start on the same Nuxt page, further down.

c15t's vendor integrations were already declarative. Each one is a manifest listing the globals to create, the setup steps to run and the consent signal to send. Nothing has to execute to describe a vendor, so the server can write those steps into an inline script.

In Next.js you don't change anything. The usual root layout already passes the server's state to ConsentRoot:

// app/layout.tsx import { ConsentBanner, ConsentRoot } from 'c15t/next'; import { resolveConsent } from 'c15t/next/server'; import type { ReactNode } from 'react';

const RootLayout = ({ children }: { children: ReactNode }) => (

<html lang="en"> <body> <ConsentRoot state={resolveConsent()}> {children} <ConsentBanner /> </ConsentRoot> </body> </html> );

On a returning visit, this happens:

The server reads the cookie. resolveConsent() reads the visitor's stored choice before any HTML goes out.

It renders an inline script. If that choice allows some scripts entries, ConsentRoot renders a small script after the page content.

The browser checks again. After first paint, the script checks Global Privacy Control, expiry and the cookie itself. If a check fails, it does nothing.

Setup runs, then the tags go in. Each vendor's setup runs first, so calls like fbq(&#x27;track&#x27;, ...) queue as they always did. The tags wait for any fetchpriority="high" image that's still loading.

The loader adopts the tags. When the app hydrates, c15t's script loader finds the tags by their resource key and doesn't insert its own.

The inline script checks the same things the loader does, so it can't start a script the loader wouldn't. It does nothing if the browser sends a Global Privacy Control signal the server didn't read, if the stored decision has expired, or if the consent cookie has a field the browser's decoder would reject. A parity test runs the server start and then the runtime for every consent state, and checks the page ends with the same vendor elements as a page that ran only the runtime, never two of one.

Nothing starts from the HTML in these cases:

Nuxt, SvelteKit and TanStack Start work the same way from their own server state. The Nuxt module adds the script at the end of <body>. In SvelteKit and TanStack Start, ConsentRoot renders it from the state your root load passes in.

To turn it off, so every scripts entry starts after hydration:

The bench is a Next.js site for a London pigeon courier, with a 306 KB hero image and the real Google tag, Meta Pixel and TikTok Pixel scripts, loaded with test IDs. Playwright runs it with 4x CPU throttling, 170 ms latency and 1.125 MB/s over HTTP/2. The arms are interleaved, and each number is the median of 10 loads, in ms from navigation start, for a returning visitor who accepted everything.

Against c15t's old path, the first tag goes in 46% sooner and each vendor loads 14% to 16% sooner.

Against OneTrust, the first tag goes in 55% sooner and the vendor beacons fire 20% to 22% sooner. OneTrust is a script tag, so it never waits for hydration. Its page even hydrated first, at 1,616 ms. Its first tag still went in at 2,406 ms, later than c15t's old path that waits for hydration. Before OneTrust runs a tag, it loads a stub, then its config, then a geolocation lookup, then otBannerSdk.js and only then calls OptanonWrapper. Each request waits for the one before it. We timed those from Resource Timing, the way CookieBench does.

Most CMPs are built the same way. CookieYes, Usercentrics, Didomi, iubenda, Ketch and Osano are each a script from their own CDN, so no tag runs until that script has downloaded from another origin and run. Most fetch more before they're done. In CookieBench's request log, Usercentrics fetched six files, including its ruleset and settings, and Ketch fetched a boot script, its SDK, an IP lookup and its config. CookieYes was the shortest, at one file. Either way, the visitor's choice is already in their browser, and these scripts can only read it once they've loaded there. We only timed OneTrust on this bench, so there are no numbers for the others here.

next/script doesn't check consent at all, and it still inserts its first tag 700 ms after the server start. Meta Pixel and TikTok Pixel load sooner with the server start too, by 87 ms and 197 ms. The Google tag is the one vendor next/script loads sooner, at 1,986 ms against 2,631, because it preloads gtag.js in <head>, so that request starts at about 77 ms. We tried preloads like that for c15t and dropped them, because they delayed the hero image. That's covered below. The beacons also go out about 170 ms sooner with next/script: 2,903 ms against 3,067 for Meta, and 2,857 against 3,030 for TikTok.

@next/third-parties loads the Google tag through its GoogleAnalytics component, which renders next/script. It has no component for Meta or TikTok, so that arm loads them through next/script, as Next's guide shows. In a separate run with the same setup, it inserted its first tag at 1,663 ms and loaded the last vendor at 2,309, next to next/script at 1,672 and 2,340. The server start did both sooner, at 946 ms and 2,079, with the Google tag included.

For the other frameworks we added a returning-visitor page to each bench app, with the server start on and off in the same build. The vendor scripts are small stand-ins for Google tag, Meta Pixel and TikTok Pixel, served from a separate origin the way a vendor's CDN is. The throttling matches the Next.js bench. Each number is the median of 15 loads, with the arms interleaved.

With the server start on, all three send the first vendor request about 10 ms to 20 ms after first paint. The gain depends on how long each framework takes to hydrate and read consent. Nuxt takes the longest, so it gains the most.

First paint and hydration stayed within noise on Nuxt and SvelteKit. TanStack Start paid a little: the load event came 55 ms later and hydration 30 ms later, or 88 ms at the 90th percentile. Its vendor downloads now land before load and compete with the app's chunks.

Where the vendor scripts come from matters. In an earlier run we served the stand-ins from the app's own origin. The gain shrank to 120 ms to 400 ms, and load moved 250 ms to 350 ms later on Nuxt and TanStack Start, because the vendor requests queued behind the app's chunks for the same six connections. Real vendors load from their own origins, so the separate origin is the fairer test. A tag you self-host or proxy through your own domain behaves like the first run.

Nuxt sites often load tags with @nuxt/scripts, so we added version 1.3.12 to the Nuxt bench as three more arms in the same build: its consent trigger, fed by c15t's consent state, its default trigger and trigger: 'server'. Every arm loads the same stand-ins from the same origin. This run was on a busier machine, so every arm hydrated at about 1.8 s. Compare the arms with each other, not with the table above.

Of the options we measured, the server start is the only one that waits for consent and still starts before hydration.

The first tag goes in 927 ms sooner, but the last vendor only finishes 434 ms sooner. From insert to loaded now takes about 1,560 ms, against 1,065 ms before.

Before, the tags downloaded after the app's JavaScript had arrived, so they had the connection to themselves. Now they download alongside it on the same throttled link, and they still have to run on a main thread that's busy hydrating.

The obvious fix is to start the downloads even sooner with a preload. We tried that first.

The rule from the start was that we don't trade how fast the page shows up for how soon tags start. In the shipped version, first paint lands within 48 ms of c15t's old path, which is noise, and so does largest contentful paint. Getting there meant finding every place an earlier version broke that rule.

A <link rel="preload"> in <head> for each allowed vendor loads them about 700 ms sooner. It also delays the hero image by 300 ms, largest contentful paint by 200 ms and hydration by 600 ms. fetchpriority="low" doesn't help, because the vendor downloads still share the connection with the page's image and JavaScript. A preconnect to the vendor origins did nothing measurable in either direction over 30 rounds. Both are out.

The first version rendered the inline script before the page content. That put it at the top of <body>, after two stylesheets, and an inline script after a stylesheet can't run until the stylesheet loads. The parser stopped there, and first paint went from 388 ms to 444 ms. Rendering the script after the content brought it down to 408 ms.

The second time showed up on the bigger page, as about 40 ms of extra first paint. A trace showed about 28 ms of the script's setup running in the same task as first paint. It n

正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。

打开原文
去社区讨论 →