<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Jesse Wunderlich — writing</title><description>Notes from Jesse Wunderlich on building small things on the web.</description><link>https://www.jesse-wunderlich.com/</link><language>en-us</language><item><title>A visitor counter that doesn&apos;t track you</title><link>https://www.jesse-wunderlich.com/writing/visitor-counter-that-doesnt-track-you/</link><guid isPermaLink="true">https://www.jesse-wunderlich.com/writing/visitor-counter-that-doesnt-track-you/</guid><description>How the odometer in this site&apos;s sidebar works: one integer, a daily-rotating hash, and no stored IP addresses.</description><pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Look in the sidebar and you’ll see a little odometer. I wanted one because I miss the web where a hit counter was just a fun thing on the bottom of a page. I did not want to bring along the tracking that came with the modern version of it. So I built my own, and here is exactly what it does.&lt;/p&gt;
&lt;h2 id=&quot;what-you-see&quot;&gt;What you see&lt;/h2&gt;
&lt;p&gt;The counter is an SVG image. When your browser loads the page, it asks my server for &lt;code&gt;/api/counter&lt;/code&gt;, and the server draws the digits and sends back a picture. There is no script on the page for it. That is why the site can keep a strict content security policy and ship no client JavaScript for this.&lt;/p&gt;
&lt;h2 id=&quot;what-it-stores&quot;&gt;What it stores&lt;/h2&gt;
&lt;p&gt;The count lives in Upstash Redis. The main thing stored is one integer: the total.&lt;/p&gt;
&lt;p&gt;That alone would count every reload, so I wanted something closer to unique visitors per day. When a request comes in, the server takes your IP address and your browser’s user-agent string and runs them through an HMAC (a keyed hash). The key is a secret that changes every day. It is derived from the Redis token plus the current UTC date, so yesterday’s hashes can’t be matched against today’s.&lt;/p&gt;
&lt;p&gt;That hash becomes a short-lived key in Redis, set only if it doesn’t already exist, and set to expire after 24 hours. If the key was new, the counter goes up by one. If it already existed, you were already counted today, and the number stays put.&lt;/p&gt;
&lt;p&gt;Your raw IP address is never written to Redis and never logged by the counter code. The only thing that exists is the hash, and it disappears on its own after a day.&lt;/p&gt;
&lt;h2 id=&quot;the-small-rules&quot;&gt;The small rules&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;It only answers GET and HEAD requests. Anything else gets a 405.&lt;/li&gt;
&lt;li&gt;Requests that look like bots, crawlers, link previewers, and monitoring tools can read the number but don’t add to it.&lt;/li&gt;
&lt;li&gt;If Redis is unreachable, the page doesn’t break. The odometer shows a row of dashes.&lt;/li&gt;
&lt;li&gt;The response isn’t cached, so the number is current.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;why-bother&quot;&gt;Why bother&lt;/h2&gt;
&lt;p&gt;Mostly because I can, and because I think it’s a good trade. I get the fun of a counter. You get a page that doesn’t set cookies, doesn’t load third-party trackers, and doesn’t build a profile of you. The whole thing is one small file, and this post describes everything it does.&lt;/p&gt;
&lt;p&gt;It isn’t perfect. A hash of an IP and a user-agent is a rough stand-in for “a person.” Two people on the same network with the same browser will look like one visitor, and one person on a phone and a laptop will count twice. I’m fine with that. It’s a counter, not an audit.&lt;/p&gt;
&lt;p&gt;If you want the plain-English version of everything this site collects, it’s on the &lt;a href=&quot;/privacy/&quot;&gt;privacy page&lt;/a&gt;. If you want to see how the rest of it is put together, check the &lt;a href=&quot;/colophon/&quot;&gt;colophon&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item></channel></rss>