privacy is a list, not an adjective: what this site actually sends
dev · Oct 4, 2026 · 7 min read
i was going to write that this site does not send anything anywhere. no analytics, no trackers, no cookies, everything first-party. the sentence was already written in my head and it felt great.
then i opened the network panel and counted, because a claim like that is either true or a lie and there is no third option.
it sent three requests to three different origins on a cold load, and the source contains two more that hand a stranger my IP address.
what actually leaves the browser
this is the network log of a cold load of the homepage, complete:
| destination | what it is | who they learn |
|---|---|---|
fonts.googleapis.com |
css for JetBrains Mono | that you are on this site |
fonts.gstatic.com |
the JetBrains Mono woff2 | ditto |
there is a fourth row, and it is the only one i chose to put there:
| destination | what it is | who they learn |
| --- | --- | --- |
| `www.googletagmanager.com` | Google Analytics 4 | **that you visited a page here, and when** |
that one is real, i added it on purpose, and it does not fire unless you press a button first. `src/lib/analytics.ts` keeps the answer in `localStorage`, and `gtag.js` is injected only after `granted`. refuse, and the script is never fetched and a `no` is remembered; ask again on every visit and some people read that as pressure, so it is asked once. **before you choose, this table is three rows, not four.**
that last row is the one i need to talk about. the full request was:
/v1/forecast?latitude=44.4949&longitude=11.3426¤t=temperature_2m,...
my coordinates, in plaintext, in a url, to show me the temperature. and here is where they came from — or rather, where they can come from, because on this particular load they did not need to. detectUserLocation() in HomePage.tsx tries get.geojs.io/v1/ip/geo.json and, if that fails, ipwho.is. both of those are IP geolocation services, which means both of them are handed your IP address as the price of a postcode. they did not fire here because the widget already had a location; when it does not, a visitor's IP goes to two strangers in sequence so that a dropdown can be skipped.
so the honest count is three requests on a cold load and five endpoints in the source, and four of them exist to serve a single widget. i built that without a moment of thought.
what is not there is still the more interesting part, even after the analytics. no tag manager. no error reporting. no session replay. no pixel, no beacon, no document.cookie anywhere in the source. settings live in localStorage and go nowhere. the app, the styles, the images, the two UI fonts: all from my own origin, one hostname, no third party.
that last sentence is worth defending properly, because "no third party" is not true and never was — the table above is three rows long and every one of them is somebody else's server. what is true is narrower: **the app itself never talks to anyone.** one origin serves the code, the styles and the images. the third parties are called by the browser, from the page, and the two that matter can be switched off without touching a line of the app.
and the analytics row is the honest kind of addition. i could have written this post a year from now and quietly let the sentence at the top rot, and that is the move i did not make, because a privacy post that goes stale is worse than no privacy post: it teaches people the wrong lesson about the value of checking.
the part that is genuinely good, and that nobody screenshots
the favicon costs zero requests. this site repaints its tab icon at runtime, in the current theme colour, from the sigma logo path baked into the bundle:
const href = `data:image/svg+xml;utf8,${encodeURIComponent(buildSigmaFavicon(color))}`;
document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]').forEach((link) => {
link.href = href;
});
switch the accent and the tab icon follows, because it is a data uri, so there is nothing to fetch. there is no /favicon.ico entry in the network log at all. the static .ico, the apple-touch-icon and the manifest pngs are still there, and they have to be: they are what you get before react mounts and on the clients that cannot run javascript or cannot render svg. the last <link> wins where both are supported, so the vector one goes last.
it is a small thing, and it is the clearest example of privacy-by-construction i have in the codebase: the feature is not "we do not track you", it is "this specific request does not exist".
no sourcemaps. one line in vite.config.ts:
build: {
sourcemap: false,
}
a sourcemap is a complete map of your source, shipped to everyone who loads the page, usually by accident because it was useful once in devtools. it is the most private setting in a bundler and the easiest to forget. dist/ has no .map file in it.
hashed filenames are not privacy
every asset on this site has a content hash in its filename: a/ and c/ for the js chunks, s/ for the css. i am deliberately not pasting one here, because it changes on every build and this paragraph would be wrong by tomorrow.
people list hashed filenames under privacy posts. it is not privacy, it is caching: the hash is there so a deploy does not invalidate a year of browser cache. the hash has nothing to do with anyone's address.
i am writing it here because conflating the two is exactly what makes "privacy website" posts worthless. a bundle with a content hash in the name is not private. a 10 MB third-party script is not private either, whether its url has a hash in it or not.
for what it is worth, "size" means three different numbers for the same entry chunk: the file on disk, what a gzip server would send, and what brotli actually puts on the wire. that last one lands at roughly a quarter of the first. i am not pasting exact byte counts here, and the reason is embarrassing: this post is inside the bundle it is describing, so every number i write changes the number, and the paragraph would be wrong by the next deploy. one entry chunk, hashed, and about a quarter of its on-disk size on the wire.
the jetbrains mono problem, which is embarrassing
everything above about self-hosting fonts is true, and then there is this, on line 1 of src/index.css:
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap");
and the variable right below it:
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
two problems, and the second one is the interesting one.
the first is that every other typeface on this site is served from my own origin — GoogleSansFlex-Variable.woff2 and MaterialSymbolsRounded.woff2, both in public/fonts/, both preloaded — and one @import puts google back in the room. a visitor's browser now tells google which page they are on and roughly where from, in exchange for a monospace font that most of them will never see rendered.
the second is that the font is behind a setting. there is a "developer font" toggle in the settings dialog that switches the whole ui to JetBrains Mono. almost nobody turns it on. and the @import does not care: a css @import at the top of a stylesheet is unconditional, so it is fetched on every cold load whether you ever open that toggle or not. the privacy-relevant code path and the user-facing code path are not the same code path, and the convenience one is the one that always runs.
the fix is one line. the stack already has ui-monospace, SFMono-Regular and monospace behind it — three fonts that cost zero requests — so deleting the import degrades nothing except the glyph shapes.
the fonts i ship and nobody loads
this is not privacy, it is honesty about my own repo:
| file | bytes | referenced by |
|---|---|---|
MaterialSymbolsRounded.woff2 |
5,345,304 | index.html, index.css |
GoogleSansFlex-Variable.woff2 |
1,945,520 | index.html |
RobotoFlex-Variable.ttf |
1,787,292 | nothing |
GoogleSansFlex-Latin.woff2 |
20,076 | nothing |
9,098,192 bytes in dist/fonts/, 1,807,368 of which no page ever asks for. they are in public/, so they are copied into every deploy, they are fetchable by anyone who guesses the url, and they will still be there in three years because nothing warns you about a file that is never requested.
and the 20,076-byte one is embarrassing in a specific way: it is the 20 KB latin subset I wrote a whole post about. i measured it, i shipped the 1,945,520-byte variable font instead because i decided the full one was the right call, and then i never deleted the subset. the file survived the decision that overruled it. the number in my post is still correct and the file is still unused.
what "privacy as a design choice" has to mean
not a disclaimer. not a cookie banner with nothing to consent to. the test is one question:
can you state, without hedging, what leaves the browser?
if you cannot, you have a privacy policy. a policy is a document you wrote once. if you can — and the list is short, and every entry is a feature you chose on purpose — then you have a design decision. mine is four requests, and after today i can name all four and decide about each one.
that is the whole discipline. not "we respect your privacy". a list, checked against the network panel, with the coordinates in it.
what i would change, in this order
1. delete the @import. the fallback stack is already there. one line, removes two third-party origins, changes nothing anyone can see unless they enabled a setting most people never touch.
2. put the weather behind a click. this is the real failure and it is mine, not theirs: to save myself and the visitor one dropdown interaction, two companies learn where the visitor is and a third is told. the geolocation should happen after a click on the weather widget, never before, and never by default. if the widget is not clicked, nobody should be located.
3. delete the two dead fonts. 1.8 MB that no page requests, in every deploy, forever.
what i would tell my past self
- a privacy claim is a list with names in it. "sends nothing" is an adjective. "sends nothing except these four things, and i can see all four in the panel" is a fact you can be caught on, which is the point.
- an
@importis not a setting. i thought i had made the monospace font optional. i had made it visible or not, which is not the same thing, and the network request was there either way. the feature and the privacy are different code paths and only one of them is behind the toggle. - the numbers in your posts rot faster than your bundles. the 20 KB subset is still sitting in this repo, unused, being the exact size i wrote about while the 1.9 MB file ships. if you ever correct a number, check whether the file it described still exists — or delete it while you are there.