privatsphäre ist eine liste, kein adjektiv: was diese seite wirklich sendet
dev · Oct 4, 2026 · 7 min read
ich wollte schreiben, dass diese seite nirgendwohin etwas sendet. kein analytics, keine tracker, keine cookies, alles first-party. der satz war schon in meinem kopf geschrieben und fühlte sich großartig an.
dann habe ich das netzwerk-panel geöffnet und gezählt, denn eine behauptung wie diese ist entweder wahr oder eine lüge, und es gibt keine dritte möglichkeit.
**er hat bei einem kalten load drei anfragen an drei verschiedene origins geschickt, und der quellcode enthält zwei weitere, die einem fremden meine ip-adresse geben.**
## was wirklich aus dem browser herausgeht
das ist das netzwerk-log eines kalten loads der homepage, vollständig:
| ziel | was es ist | was sie über mich lernen |
| --- | --- | --- |
| `fonts.googleapis.com` | css für **JetBrains Mono** | dass ich auf dieser seite bin |
| `fonts.gstatic.com` | die JetBrains-Mono-woff2 | dito |
| `api.open-meteo.com` | das wetter-widget | **meinen breitengrad und längengrad, im query-string** |
es gibt eine vierte zeile, und es ist die einzige, die ich bewusst dort hingelegt habe:
| ziel | was es ist | was sie über mich lernen |
| --- | --- | --- |
| `www.googletagmanager.com` | Google Analytics 4 | **dass ich eine seite hier besucht habe, und wann** |
die ist echt, ich habe sie absichtlich hinzugefügt, und sie feuert nur, wenn du zuerst einen knopf drückst. `src/lib/analytics.ts` hält die antwort in `localStorage`, und `gtag.js` wird erst nach einem `granted` eingefügt. lehnst du ab, wird das skript nie geladen und ein `no` wird gemerkt; fragt man bei jedem besuch erneut, lesen das manche als druck, also wird einmal gefragt. **bevor du wählst, ist diese tabelle drei zeilen lang, nicht vier.**
über diese letzte zeile muss ich reden. die vollständige anfrage war:
```
/v1/forecast?latitude=44.4949&longitude=11.3426¤t=temperature_2m,...
```
meine koordinaten, im klartext, in einer url, um mir die temperatur zu zeigen. und hierher kommen sie — besser gesagt, von wo sie kommen *können*, denn bei diesem einen load brauchten sie es nicht. `detectUserLocation()` in `HomePage.tsx` versucht `get.geojs.io/v1/ip/geo.json` und, wenn das scheitert, `ipwho.is`. **beides sind ip-geolokalisierungsdienste**, das heißt, beide bekommen deine ip-adresse als preis für eine postleitzahl. sie sind hier nicht gefeuert, weil das widget bereits einen standort hatte; wenn es keinen hat, landet die ip eines besuchers nacheinander bei zwei fremden, nur damit ein dropdown übersprungen werden kann.
die ehrliche zahl ist also drei anfragen bei einem kalten load und fünf endpoints im quellcode, und vier davon existieren, um ein einziges widget zu bedienen. das habe ich gebaut, ohne einen moment nachzudenken.
was *nicht* da ist, ist trotzdem der interessantere teil, auch nach dem analytics. **kein tag manager.** kein fehlerreporting. kein session replay. kein pixel, kein beacon, kein `document.cookie` irgendwo im quellcode. die einstellungen leben in `localStorage` und gehen nirgendwohin. die app, die styles, die bilder, die beiden ui-fonts: alles von meinem eigenen origin, ein hostname, keine dritte partei.
dieser letzte satz ist es wert, ordentlich verteidigt zu werden, denn „keine dritte partei" ist nicht wahr und war es nie — die tabelle oben ist drei zeilen lang und jede ist der server von jemand anderem. wahr ist etwas schmaleres: **die app selbst redet mit niemandem.** ein origin liefert den code, die styles und die bilder. die dritten parteien ruft der browser auf, aus der seite heraus, und die beiden, auf die es ankommt, lassen sich abschalten, ohne eine zeile der app zu ändern.
und die analytics-zeile ist die ehrliche art von ergänzung. ich hätte diesen artikel in einem schreiben können und die satzes oben still verrotten lassen, und das wäre der zug gewesen, den ich nicht gemacht habe, denn ein privacy-artikel, der veraltet, ist schlimmer als gar keiner: er bringt den leuten die falsche lehre über den wert des nachprüfens bei.
## der teil, der wirklich gut ist, und den niemand screenshotet
**die favicon kostet null anfragen.** diese seite malt ihr tab-symbol zur laufzeit in der aktuellen themafarbe neu, aus dem im bundle gebackenen pfad des sigma-logos:
```tsx
const href = `data:image/svg+xml;utf8,${encodeURIComponent(buildSigmaFavicon(color))}`;
document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]').forEach((link) => {
link.href = href;
});
```
wechselst du die akzentfarbe, folgt das tab-symbol, weil es eine data-uri ist, es gibt also nichts zu laden. **im netzwerk-log gibt es überhaupt keinen eintrag `/favicon.ico`.** das statische `.ico`, das apple-touch-icon und die manifest-pngs sind noch da, und sie müssen es sein: sie sind das, was du bekommst, bevor react mountet, und auf clients, die kein javascript ausführen oder kein svg rendern können. der letzte `<link>` gewinnt, wo beide unterstützt werden, also steht der vektorversion ganz hinten.
es ist eine kleine sache, und sie ist das klarste beispiel für privacy-by-construction, das ich im codebase habe: die funktion ist nicht „wir verfolgen dich nicht", sie ist „diese bestimmte anfrage existiert nicht".
**keine sourcemaps.** eine zeile in `vite.config.ts`:
```ts
build: {
sourcemap: false,
}
```
eine sourcemap ist eine vollständige karte deines quellcodes, ausgeliefert an jeden, der die seite lädt, meistens versehentlich, weil sie einmal in den devtools nützlich war. es ist die privateste einstellung eines bundlers und die leichteste zu vergessen. `dist/` enthält keine `.map`-datei.
## gehashte dateinamen sind keine privacy
jedes asset dieser seite hat einen inhalts-hash im dateinamen: `a/` und `c/` für die js-chunks, `s/` für das css. ich füge hier bewusst keines ein, weil es sich bei jedem build ändert und dieser absatz morgen falsch wäre.
leute listen gehashte dateinamen unter privacy-artikeln auf. das ist keine privacy, das ist caching: der hash ist da, damit ein deploy nicht ein jahr browser-cache invalidiert. der hash hat nichts mit der adresse von irgendjemandem zu tun.
ich schreibe es hier hin, weil genau das verwechseln die „privacy-website"-artikel wertlos macht. ein bundle mit inhalts-hash im namen ist nicht privat. ein 10-mb-skript von dritter partei ist ebenfalls nicht privat, egal ob seine url einen hash hat oder nicht.
fürs protokoll: „größe" bedeutet drei verschiedene zahlen für denselben entry-chunk: die datei auf der platte, was ein gzip-server senden würde, und was brotli tatsächlich auf die leitung legt. das letzte landet bei ungefähr einem viertel des ersten. ich schreibe hier keine exakten byte-zahlen hin, und der grund ist peinlich: **dieser artikel steckt im bundle, das er beschreibt**, also ändert jede zahl, die ich schreibe, die zahl, und der absatz wäre beim nächsten deploy falsch. ein entry-chunk, gehasht, und etwa ein viertel seiner on-disk-größe auf der leitung.
## das jetbrains-mono-problem, das ist peinlich
alles oben über selbst gehostete fonts ist wahr, und dann gibt es das hier, in zeile 1 von `src/index.css`:
```
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap");
```
und die variable direkt darunter:
```
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
```
zwei probleme, und das zweite ist das interessante.
das erste: jede andere schrift auf dieser seite kommt von meinem eigenen origin — `GoogleSansFlex-Variable.woff2` und `MaterialSymbolsRounded.woff2`, beide in `public/fonts/`, beide vorab geladen — und ein einzelnes `@import` holt google wieder in den raum. der browser eines besuchers sagt google jetzt, welche seite er ansieht und ungefähr woher, im gegenwert für einen monospace-font, den die meisten nie gerendert sehen werden.
das zweite: **der font steckt hinter einer einstellung.** es gibt einen „developer font"-schalter im einstellungsdialog, der die ganze ui auf JetBrains Mono umstellt. fast niemand schaltet ihn ein. und das `@import` kümmert sich nicht: ein css-`@import` oben in einem stylesheet ist bedingungslos, er wird also bei jedem kalten load geladen, ob du den schalter je öffnest oder nicht. der datenschutzrelevante codepfad und der benutzerrelevante codepfad sind nicht derselbe codepfad, und der bequeme ist der, der immer läuft.
die behebung ist eine zeile. der stack hat `ui-monospace`, `SFMono-Regular` und `monospace` ohnehin schon dahinter — drei fonts, die null anfragen kosten — also verschlechtert das löschen des imports nichts außer den glyphenformen.
## die fonts, die ich ausliefer und niemand lädt
das ist keine privacy, das ist ehrlichkeit über mein eigenes repo:
| datei | bytes | referenziert von |
| --- | --- | --- |
| `MaterialSymbolsRounded.woff2` | 5,345,304 | `index.html`, `index.css` |
| `GoogleSansFlex-Variable.woff2` | 1,945,520 | `index.html` |
| `RobotoFlex-Variable.ttf` | 1,787,292 | **nichts** |
| `GoogleSansFlex-Latin.woff2` | 20,076 | **nichts** |
9.098.192 bytes in `dist/fonts/`, davon 1.807.368, die keine seite je anfragt. sie liegen in `public/`, werden also in jedes deploy kopiert, sind von jedem abrufbar, der die url errät, und in drei Jahren immer noch da, weil nichts dich vor einer datei warnt, die nie angefragt wird.
und die datei mit 20.076 bytes ist auf eine ganz bestimmte art peinlich: **es ist das 20-kb-latein-subset, über das ich einen ganzen artikel geschrieben habe.** ich habe es gemessen, ich habe stattdessen den 1.945.520-byte-variable-font ausgeliefert, weil ich entschied, der vollständige sei die richtige wahl, und dann habe ich das subset nie gelöscht. die datei überlebte die entscheidung, die sie aufgehoben hat. die zahl in meinem artikel stimmt immer noch, und die datei wird immer noch nicht benutzt.
## was „privacy als designentscheidung" bedeuten muss
kein disclaimer. kein cookie-banner, in dem es nichts zu akzeptieren gibt. der test ist eine frage:
**kannst du ohne ausweichen sagen, was aus dem browser herausgeht?**
wenn nicht, hast du eine privacy-policy. eine policy ist ein dokument, das du einmal geschrieben hast. wenn du es kannst — und die liste ist kurz, und jeder eintrag ist ein feature, das du absichtlich gewählt hast — dann hast du eine designentscheidung. meine sind vier anfragen, und ab heute kann ich alle vier benennen und über jede einzeln entscheiden.
das ist die ganze disziplin. nicht „wir respektieren deine datenschutz". eine liste, gegen das netzwerk-panel geprüft, mit den koordinaten darin.
## was ich ändern würde, in dieser reihenfolge
**1. das `@import` löschen.** der fallback-stack ist schon da. eine zeile, entfernt zwei drittanbieter-origins, ändert nichts sichtbares außer für leute, die eine einstellung aktiviert haben, die fast niemand anfasst.
**2. das wetter hinter einen klick legen.** das ist der eigentliche fehler, und er ist meiner, nicht ihrer: um mir und dem besucher eine dropdown-interaktion zu sparen, erfahren zwei firmen, wo der besucher ist, und ein dritter wird davon informiert. die geolokalisierung sollte nach einem klick auf das wetter-widget stattfinden, niemals davor, und niemals als voreinstellung. wird das widget nicht angeklickt, sollte niemand lokalisiert werden.
**3. die zwei toten fonts löschen.** 1,8 mb, die keine seite anfragt, in jedem deploy, für immer.
## was ich meinem vergangenen ich sagen würde
* **eine datenschutzbehauptung ist eine liste mit namen darin.** „sendet nichts" ist ein adjektiv. „sendet nichts außer diesen vier sachen, und ich sehe alle vier im panel" ist eine tatsache, bei der man erwischt werden kann, und genau darum geht es.
* **ein `@import` ist keine einstellung.** ich dachte, ich hätte den monospace-font optional gemacht. ich hatte ihn *sichtbar* oder nicht gemacht, was nicht dasselbe ist, und die anfrage war trotzdem da. das feature und der datenschutz sind verschiedene codepfade, und nur einer liegt hinter dem schalter.
* **die zahlen in deinen artikeln veralten schneller als deine bundles.** das 20-kb-subset liegt immer noch ungenutzt in diesem repo, genau so groß wie ich geschrieben habe, während die 1,9-mb-datei ausgeliefert wird. wenn du jemals eine zahl korrigierst, prüfe, ob die datei, die sie beschrieb, noch existiert — oder lösche sie, wenn du schon mal dabei bist.