Oct 15, 2026 · Updated Oct 15, 2026
Visits without cookies, and the path they took
Question: How does a web analytics counter such as GoatCounter tell visits apart without a cookie; what else can a page report without fingerprinting the visitor; can the path a visit took through a site be reconstructed from that; and what would a replacement look like, if it had to be a few kilobytes in the browser and run anywhere as one binary?
Short answer: GoatCounter's client sends the path, referrer, title, query string and screen width, and the server derives browser, system, country and language from the request itself; a visit is the concatenation of site, User-Agent and IP address held in memory for eight hours and mapped to a random id, with the IP and User-Agent never written to disk [1], [2]. That is the ceiling of what a counter can know about a person without storing anything in their browser, and it is a day-sized ceiling: nothing links two days. Within it, a page can add a good deal the browser will say unasked: viewport and pixel ratio, the colour scheme and motion preference, touch, timezone, the navigation type, how long the page was visible, how far it was scrolled, and real-user Core Web Vitals, all without touching any API that identifies a device. The journey needs no new data at all: a same-site referrer is the previous page, and GoatCounter discards it. The line past which this stops being a counter is fingerprinting, which the Article 29 Working Party placed inside Article 5(3) of the ePrivacy Directive in 2014 and the EDPB's 2024 guidelines reaffirm; those guidelines also read the article broadly enough to cover JavaScript sending "targeted information" and, in some cases, an IP address alone, while noting that applicability does not by itself mean consent is required [3]. The design that follows, lib-visits, is a client of 3,068 bytes minified plus an optional 995-byte Web Vitals module, against the 9,373 bytes of count.js served today, and a Rust collector that runs as one static binary and emits decorated events as JSON lines, with the visit as a stateless keyed hash of the day, the address and the User-Agent.
GoatCounter's count.js is 9,373 bytes unminified and sends one request per hit, a GET whose query string carries p (the path, preferring a same-host <link rel=canonical>), r (the referrer), t (the title), e (an event flag for data-goatcounter-click elements), s (screen.width, the width only), b (a bot code, 150 to 153, for PhantomJS, Nightmare, Selenium and navigator.webdriver), q (location.search, the whole query string) and rnd, a random string "to avoid caching" [4]. It sends with navigator.sendBeacon and falls back to a one-pixel <img>, which the code comments say exists because the beacon "mostly fails due to being blocked by CSP" [4]. It skips prerendered pages, iframes and local hosts, and it reads localStorage once: a skipgc key, set by loading any page with #toggle-goatcounter, silences it in that browser [4]. So even the reference cookieless counter touches storage, for the visitor's own opt-out.
Everything else is the server's. The privacy policy lists what is stored: browsers and systems "based on headers sent by the browser", locations "based on IP address", languages, and screen widths, each as a separate per-day aggregate with "no way to link data between them"; pageviews and referrers per hour; and, only if a site turns it on, individual pageviews with a session id, an entry flag, a bot flag and the same derived fields [2]. "Only the end result is stored (browser name and version) and not the source where we retrieved it from (User-Agent header)" [2].
The visit is the interesting part. GoatCounter counts visits, not pageviews: "A 'visit' is the first time someone loads a page. Someone reloading the page or going to another page and then coming back is counted as one visit" [1]. The mechanism, in its own words: "A sessionID is created as concat(siteID, User-Agent, IP). Store this in memory as a sessionID → UUIDv4 mapping for 8 hours", with a second in-memory map of paths each session has seen, so a path counts as a new visit once per session; "The IP address and User-Agent are never stored to the database or disk, and there is no conceivable way to trace the random UUID back to this" [1]. The policy adds that the mapping "is only stored in memory (for up to eight hours)" and that GoatCounter "doesn't store IP addresses, the full User-Agent header, or any tracker ID" and stores nothing "in the browser with cookies, localStorage, cache, or any other method" [2].
Two consequences follow. First, the unit is the browser-plus-address for the day: the same person on the same laptop tomorrow is a new visitor, and so is the same person on the office network after a router change. GoatCounter chose that deliberately; it is what lets its GDPR page argue that "it's essentially impossible to identify any person, even with full access to the database" and that counting is a legitimate interest, "a store keeping track of how many people enter through which doors" [5]. Second, the session table is process memory, which is fine for one Go process and awkward for anything that scales to zero or runs two instances, a point section 6 returns to.
GoatCounter's dashboard API, which the author's admin page read until this week, serves the aggregates at four requests a second: totals, hits per path, referrers, campaigns, locations, sizes, browsers, systems and languages [6]. On 2026-10-15 its single server reset most TLS handshakes for hours, and five cards on that page failed with it. The outage is the occasion for this note, not its argument; the argument is that the cookieless counter leaves useful things on the table.
A page can read a number of things about its own environment that describe the visit rather than the visitor, cost a few bytes each, and need no API that is built to tell devices apart. GoatCounter sends one of them, screen.width. The rest, with the reason each earns its bytes:
| Signal | From | Why |
|---|---|---|
| Viewport, pixel ratio | innerWidth, innerHeight, devicePixelRatio |
the layout question: at which widths is the page actually seen, and on how dense a screen; the screen's width alone does not say how wide the browser window is |
| Colour scheme | prefers-color-scheme |
a site that follows the system theme with no toggle has no other way to learn the split |
| Reduced motion | prefers-reduced-motion |
whether the motion work is seen at all |
| Touch | maxTouchPoints |
touch versus pointer, better than guessing from the User-Agent |
| Timezone | Intl.DateTimeFormat |
coarser than a city, finer than a country, and it gives the local hour of the visit |
| Language | language |
what GoatCounter reads from Accept-Language server-side, which costs nothing and is the better source when present |
| Navigation type | navigation entry type |
navigate, reload or back_forward [7]: a reload is not a new visit, and a back-navigation is a different kind of arrival than a link |
Each is a value with a handful of possibilities, which is why none of them identifies anyone: a timezone and a dark theme describe a few hundred million people. They are the signals the author's own game already reads to pick a rendering tier, and together they cost about 300 bytes of client code.
What is deliberately not on the list: the WebGL renderer string, canvas and audio hashes, enumerated fonts, and the high-entropy client hints (platform version, device model, architecture). Each of those is useful exactly in proportion to how well it tells one device from another, which is the definition of a fingerprint, and section 5 is about why that is the wrong side of the line. The low-entropy client hints (Sec-CH-UA, Sec-CH-UA-Mobile, Sec-CH-UA-Platform) are different in kind: Chromium sends them unasked on every request, so the server can read brand, platform and a mobile flag for free, without the page asking for anything.
The question that started this note was whether the path a visit takes through a site, which pages in which order, can be known without a cookie. It can, because every pageview already carries its predecessor. document.referrer on a same-site navigation is the previous page's URL, so a visit's view events chain: / led to /games, /games led to /games/dayhike. GoatCounter receives that referrer on every hit and reports only the external ones; the internal edges are thrown away at the point where they would have been most useful. Keep them, group them by the visit hash, and the per-visit path falls out of a query, with no new data from the client.
What the client should add is what nothing else can see: how the page ended. The reliable moment to report that is not unload, beforeunload or pagehide, each of which MDN calls unreliable, since a mobile browser "may never fire them if the user switches apps and later closes the browser", and the first two of which keep a page out of the back/forward cache; MDN recommends visibilitychange to hidden as "the most reliable way" to send end-of-session data, with navigator.sendBeacon [8]. The beacon "is transmitted asynchronously when the user agent has an opportunity to do so, without delaying unload or the next navigation", and it always sends an HTTP POST [8].
So a page sends a leave event when it goes hidden, carrying three numbers: engaged seconds (time the page was visible, accumulated across visibility changes, which is the honest version of "time on page"), the maximum scroll depth reached, and whether a marked element at the end of the content was ever scrolled into view, which the author's site already measures for its research docs with a 549-byte script. A page may go hidden, come back and go hidden again, so leave can be sent more than once; the simplest correct rule is to carry cumulative values and let the query take the last one.
The events of one page load need to be joined to each other: a view, some leaves, the outbound clicks. A random id drawn at load and kept only in the page's memory does that; it dies with the page, it is never written to storage, and it cannot be recognised by anything, which is the difference between a join key and a tracker. The visit hash of section 6 then joins the page loads of a visit.
Outbound clicks complete the picture: a click on a link to another host, reported as the link's origin and path, says where the visit went next, and is the only exit a site can see. Named events on specific elements, the data-goatcounter-click idea, stay as they are: an attribute whose value is a stable identifier, so that renaming a button never renames its series.
Lighthouse measures a page on one machine under one throttling profile. Google's Search Console reports Core Web Vitals from real users, but only once a site has enough traffic, which a personal site may never reach. A counter that is already sending a beacon at the end of every page load is in the right place to carry the real numbers.
The four metrics each come from a PerformanceObserver or the navigation entry. Largest Contentful Paint is the last largest-contentful-paint entry's start time. Cumulative Layout Shift is defined by session windows: a window "forms when one or more individual layout shifts occur in rapid succession with less than 1-second in between each shift", lasts "a maximum of 5 seconds", and the score is "the session window with the maximum cumulative score of all layout shifts within that window"; shifts within 500 ms of user input carry hadRecentInput and are excluded [9]. Interaction to Next Paint, in Google's web-vitals library, keeps the ten longest interactions (MAX_INTERACTIONS_TO_CONSIDER = 10), counts only event-timing entries with an interactionId, and reports the one at index Math.floor(interactionCountForNavigation / 50), capped at the end of the list, as an estimate of the 98th percentile [10]; the observer's default duration threshold is 40 ms [11]. Time to First Byte is the navigation entry's responseStart.
That arithmetic fits in 995 bytes minified, and it belongs in a separate script, so that the core stays small and a site that does not want field vitals never loads them.
Everything above has stayed on one side of a line, and it is worth being precise about where the line is drawn, because "no cookies" is not where the law draws it.
Article 5(3) of the ePrivacy Directive is the provision behind cookie banners. The EDPB's Guidelines 2/2023 on its technical scope, adopted in final form on 7 October 2024, exist because "the emergence of new tracking methods to both replace existing tracking tools (for example, cookies ...) and create new business models has become a critical data protection concern" [3]. They record that the Article 29 Working Party's Opinion 9/2014 "has already clarified that fingerprinting falls within the technical scope of Article 5(3)" [3]. That settles the fingerprinting question: canvas, WebGL and audio hashes are, for this purpose, cookies.
The guidelines go further than that, and anyone building a cookieless counter should read paragraphs 32 to 34 and 54 to 56 rather than assume. "Whenever an entity takes steps towards gaining access to information stored in the terminal equipment, Article 5(3) ePD would apply", and the examples include "JavaScript code, where the accessing entity instructs the browser of the user to send asynchronous requests with the targeted information" [3]. On IP addresses, "gaining access to IP addresses would only trigger the application of Article 5(3) ePD in cases where this information originates from the terminal equipment of a subscriber or user", which "the static outbound IPv4 originating from a user's router" and IPv6 addresses do, so "unless the entity can ensure that the IP address does not originate from the terminal equipment of a user or subscriber, it has to take all the steps pursuant to the Article 5(3) ePD" [3]. And then the sentence that keeps this from being the end of cookieless analytics: "the applicability of this article does not systematically mean that consent needs to be collected", since "in each case it would have to be assessed if a consent is needed or whether an exemption under Article 5(3) ePD could apply", an assessment the guidelines explicitly do not make [3].
So the honest position is not "no cookies, therefore no banner". It is the one GoatCounter's GDPR page takes: no personally identifiable information is kept, the data is aggregate, a site owner's interest in how many people visit is legitimate, and "it should probably be safe to add GoatCounter without a GDPR consent notice", with the caveats that the author of that page is not a lawyer and that enforcement differs across member states [5]. The author of this note is not a lawyer either. What the design below does is keep every property that argument rests on: nothing identifies a device, nothing is stored in the browser, the IP is an input to a hash and never a stored value, and the hash itself cannot outlive the day. A fingerprint would break the argument for a gain the next section shows is not needed.
One more consequence of the day-sized ceiling: returning visitors across days cannot be counted without storing an identifier in the browser, and storing one is the thing a banner is owed for. A counter that wants that number has to ask. This one does not.
lib-visits is the library built on the above: a client, a Rust collector, and a wire specification between them, licensed GPL-3.0, hosting nothing.
The client is one script, visits.js, at 3,068 bytes minified under a hard budget of 3,072 enforced by a test (the first estimate of 1,536 was a gzip figure, and the honest number is the minified one), and one optional module, vitals.js, at 995 bytes under 1,024. It sends three events, view, leave and click, as described in sections 2 and 3, each by navigator.sendBeacon as a JSON string, which is a text/plain body and so a simple request that needs no preflight. There is no <img> fallback: a GET with the event in the URL is what obliges count.js to carry a cache-buster and a site to allow the counter in img-src. A data-visits-method="get" switch exists for one reason, section 6's last paragraph. The campaign parameters (utm_*, ref) are sent as fields; the rest of the query string never leaves the page, so a token in a URL never lands in a log. The only storage is the visitor's own #visits-off, as in GoatCounter. The author's site today serves count.js and its 549-byte engagement script, 9,922 bytes against a 10,000-byte script budget; the replacement is 4,063.
The visit hash improves on GoatCounter's in-memory table by being stateless: the day's salt is HMAC-SHA256(secret, YYYY-MM-DD), and the visit is the first sixteen hex characters of HMAC-SHA256(day_salt, ip + "\n" + user_agent). Every instance of the collector agrees without sharing anything, a process that scales to zero loses nothing on restart, the IP is hashed and never written, and tomorrow's salt is unrelated to today's. It is the same ceiling as section 1, reached without a session table.
The collector is written in Rust and ships as one static binary of 1.1 MB (a 0.6 MB image, compressed) that idles at 2.6 MiB of memory. For this workload, a stateless endpoint that parses a few hundred bytes of untrusted JSON, runs two HMACs and writes a line, no language is ever the bottleneck; the choice is for the smallest binary, no garbage collector and the most predictable tails, and because a collector that costs nothing to run is the better argument for self-hosting. It validates each event against the specification, decorates it with what only the request can know (server time, the visit, the origin's host, browser and system from the client hints or a small User-Agent set, a crawler flag, the referrer classified as direct, internal with the previous path or external with its host, the language, and the country if a GeoIP database is configured), and hands it to a sink. The reference sink writes one JSON line per event to standard output, which is the simplest thing to self-host and pipes into anything. There is no database and no dashboard: the host already has a log pipeline, or wants to choose one.
The access-log mode is why the GET switch exists. A host that runs nginx, Caddy or a CDN can serve the beacon path as a static 204 and let the access log be the event store, since the log line already holds the time, the address, the User-Agent, the referrer and the query the client sent. The same decoration can then run offline over the log. The cost is stated plainly: the raw log holds the IP until the log is rotated, where the running collector never writes it.
Whether to honour Global Privacy Control or the deprecated Do Not Track signal by not counting at all: the counting is anonymous, GoatCounter does not, and a site with a developer audience would lose a visible share of its numbers; the library leaves it off and will revisit if a host asks. Whether Safari's and Firefox's referrer trimming ever shortens a same-site referrer to its origin, which would cut the journey's edges; the author has not measured it and the first weeks of data will say. And how much of a site's traffic the crawler list misses: GoatCounter does "some additional filtering on the backend" [4] beyond the client's four codes, and a short User-Agent substring list is a floor, not a solution.
08
Sources
- [1]GoatCounter, "Sessions and visitors," GoatCounter documentation. Accessed: Oct. 15, 2026. [Online]. Available: https://web.archive.org/web/20260105182243/https://www.goatcounter.com/help/sessions
- [2]GoatCounter, "Privacy policy," GoatCounter documentation, Jun. 8, 2025. Accessed: Oct. 15, 2026. [Online]. Available: https://web.archive.org/web/20260105182539/https://www.goatcounter.com/help/privacy
- [3]European Data Protection Board, "Guidelines 2/2023 on Technical Scope of Art. 5(3) of ePrivacy Directive," Version 2.0, Oct. 7, 2024. Accessed: Oct. 15, 2026. [Online]. Available: https://www.edpb.europa.eu/system/files/documents/2024-10/edpb_guidelines_202302_technical_scope_art_53_eprivacydirective_v2_en_0.pdf
- [4]M. Tournoij, "count.js," GitHub repository arp242/goatcounter, public/count.js. Accessed: Oct. 15, 2026. [Online]. Available: https://github.com/arp242/goatcounter/blob/main/public/count.js
- [5]GoatCounter, "GDPR consent notices," GoatCounter documentation. Accessed: Oct. 15, 2026. [Online]. Available: https://web.archive.org/web/20260920204653/https://www.goatcounter.com/help/gdpr
- [6]GoatCounter, "JSON API," GoatCounter documentation. Accessed: Oct. 15, 2026. [Online]. Available: https://web.archive.org/web/20260103063633/https://www.goatcounter.com/help/api
- [7]MDN Contributors, "PerformanceNavigationTiming: type property," MDN Web Docs. Accessed: Oct. 15, 2026. [Online]. Available: https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigationTiming/type
- [8]MDN Contributors, "Navigator: sendBeacon() method," MDN Web Docs. Accessed: Oct. 15, 2026. [Online]. Available: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
- [9]Google, "Cumulative Layout Shift (CLS)," web.dev. Accessed: Oct. 15, 2026. [Online]. Available: https://web.dev/articles/cls
- [10]GoogleChrome, "InteractionManager.ts," GitHub repository GoogleChrome/web-vitals, src/lib/InteractionManager.ts. Accessed: Oct. 15, 2026. [Online]. Available: https://github.com/GoogleChrome/web-vitals/blob/main/src/lib/InteractionManager.ts
- [11]GoogleChrome, "onINP.ts," GitHub repository GoogleChrome/web-vitals, src/onINP.ts. Accessed: Oct. 15, 2026. [Online]. Available: https://github.com/GoogleChrome/web-vitals/blob/main/src/onINP.ts