<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>heredia.dev</title><description>Notes on software, systems, and the occasional interactive explanation.</description><link>https://heredia.dev/</link><item><title>Consistent hashing moves fewer keys</title><link>https://heredia.dev/posts/consistent-hashing-moves-fewer-keys/</link><guid isPermaLink="true">https://heredia.dev/posts/consistent-hashing-moves-fewer-keys/</guid><description>An interactive hash ring: add and remove nodes, and watch how many of the 24 keys change owner compared to plain hash % N.</description><pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The obvious way to spread keys across N servers is &lt;code&gt;hash(key) % N&lt;/code&gt;. It is one line, it is perfectly balanced, and it falls apart the moment N changes. Adding a fourth server to a pool of three does not move a quarter of the keys. It moves most of them, because the modulus changed for every key at once.&lt;/p&gt;
&lt;p&gt;If those servers are a cache, that is a cold start across the whole fleet during the exact operation, a scale-up, that you were doing because you were already under load.&lt;/p&gt;
&lt;p&gt;Consistent hashing fixes the blast radius rather than the balance. Hash the keys and the servers into the same space, arrange that space in a circle, and let each key belong to the first server it meets going clockwise. Adding a server only steals keys from its clockwise neighbour. Removing one only donates keys to its clockwise neighbour. Everything else stays put.&lt;/p&gt;
&lt;h2&gt;What to try&lt;/h2&gt;
&lt;p&gt;The counter under the ring reports how many of the 24 keys changed owner as a result of the last thing you did. That number is the whole argument.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;With the three starting nodes, remove &lt;code&gt;gamma&lt;/code&gt;. Five keys move, and they are exactly the five &lt;code&gt;gamma&lt;/code&gt; was holding. Nothing else on the ring notices.&lt;/li&gt;
&lt;li&gt;Switch to &lt;code&gt;hash % N&lt;/code&gt; and remove &lt;code&gt;gamma&lt;/code&gt; again. Seventeen of the 24 move, including keys on nodes that did not change at all.&lt;/li&gt;
&lt;li&gt;Switch back to the ring and drag virtual nodes down to 1. The split becomes 5 / 0 / 19: &lt;code&gt;beta&lt;/code&gt; lands in a short arc and ends up owning nothing.&lt;/li&gt;
&lt;li&gt;Push virtual nodes up to 12. The split converges on 8 / 8 / 8, and removing &lt;code&gt;gamma&lt;/code&gt; now moves exactly 8 keys, a clean third.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Step 3 is the honest part of the algorithm. Consistent hashing does not give you balance for free. Three points dropped at random on a circle produce very unequal arcs, and the keys follow the arcs.&lt;/p&gt;
&lt;p&gt;Virtual nodes are the fix: hash each server under several distinct labels (&lt;code&gt;gamma#0&lt;/code&gt;, &lt;code&gt;gamma#1&lt;/code&gt;, and so on) so it occupies many small arcs instead of one large one. Averaging over more arcs narrows the spread, roughly with the square root of the number of points per server. Real implementations use 100 to 500 virtual nodes per server; 12 is only enough here because the picture has to stay readable.&lt;/p&gt;
&lt;h2&gt;The lookup&lt;/h2&gt;
&lt;p&gt;Sort the virtual nodes by position once, then every lookup is a binary search for the first position greater than or equal to the key’s, wrapping to index 0 when it runs off the end:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function successor(pos, ring) {
  let lo = 0;
  let hi = ring.length;
  while (lo &amp;lt; hi) {
    const mid = (lo + hi) &amp;gt;&amp;gt; 1;
    if (ring[mid].pos &amp;lt; pos) lo = mid + 1;
    else hi = mid;
  }
  return lo === ring.length ? 0 : lo; // [!code highlight]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The highlighted line is the wrap, and it is the only part of this that is easy to get wrong. To watch it happen: turn &lt;code&gt;alpha&lt;/code&gt; and &lt;code&gt;beta&lt;/code&gt; off, turn &lt;code&gt;epsilon&lt;/code&gt; on, and trace &lt;code&gt;user:1049&lt;/code&gt;. It sits at 0.907, past every virtual node on the ring, so the arc travels through 1.0 and around to &lt;code&gt;epsilon#0&lt;/code&gt; at 0.252.&lt;/p&gt;
&lt;h2&gt;Where the model stops&lt;/h2&gt;
&lt;p&gt;The ring answers one question, “who owns this key”, and it answers it without coordination: every client that knows the membership list computes the same answer. That is the property worth having.&lt;/p&gt;
&lt;p&gt;It does not give you even load when keys are hot rather than merely numerous, it does not replicate anything (for that you take the next R distinct nodes clockwise), and it does not tell you when membership changed. That last one is somebody else’s job, usually a gossip protocol or a coordination service, and it is where the genuinely hard problems live.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This post contains an interactive diagram. &lt;a href=&quot;https://heredia.dev/posts/consistent-hashing-moves-fewer-keys/&quot;&gt;Read it on heredia.dev.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;</content:encoded></item><item><title>What Cache-Control actually promises</title><link>https://heredia.dev/posts/what-cache-control-actually-promises/</link><guid isPermaLink="true">https://heredia.dev/posts/what-cache-control-actually-promises/</guid><description>Cache-Control is a set of independent promises made to two different audiences, and most of the confusion comes from reading it as one setting.</description><pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate><content:encoded>
&lt;p&gt;&lt;code&gt;Cache-Control&lt;/code&gt; looks like one setting with a lot of possible values. It is not. It is a list of independent directives, some of which are addressed to the browser sitting on someone’s laptop and some of which are addressed to a CDN edge node in another country. Reading it as a single knob is where most caching bugs start.&lt;/p&gt;
&lt;h2&gt;Two caches, not one&lt;/h2&gt;
&lt;p&gt;Every response passes through at least two kinds of cache, and they have different trust levels:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;private cache&lt;/strong&gt; belongs to exactly one user. The browser’s HTTP cache is the obvious one.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;shared cache&lt;/strong&gt; serves many users from the same stored response. CDNs, reverse proxies, and corporate egress proxies are all shared caches.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The directives split along that line:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Directive&lt;/th&gt;
&lt;th&gt;Applies to&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;private&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;shared caches&lt;/td&gt;
&lt;td&gt;Do not store this at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;public&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;shared caches&lt;/td&gt;
&lt;td&gt;Store this even if the request was authenticated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max-age=N&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;both&lt;/td&gt;
&lt;td&gt;Fresh for N seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s-maxage=N&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;shared only&lt;/td&gt;
&lt;td&gt;Overrides &lt;code&gt;max-age&lt;/code&gt; for shared caches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-cache&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;both&lt;/td&gt;
&lt;td&gt;Store it, but revalidate before every reuse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;both&lt;/td&gt;
&lt;td&gt;Do not write it to disk or memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The one people get wrong is &lt;code&gt;no-cache&lt;/code&gt;. It does not mean “do not cache”. It means “cache this, but check with me before you serve it”. The directive that means what people think &lt;code&gt;no-cache&lt;/code&gt; means is &lt;code&gt;no-store&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP/1.1 200 OK
Cache-Control: private, max-age=0, must-revalidate
ETag: &quot;b1946ac9&quot;
Content-Type: application/json
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Freshness is a deadline, not a guarantee&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;max-age&lt;/code&gt; is measured from the moment the response was generated at the origin, not the moment your browser received it. A response that sat in a CDN for 50 seconds arrives with &lt;code&gt;Age: 50&lt;/code&gt;, and a &lt;code&gt;max-age=60&lt;/code&gt; response is then fresh for 10 more seconds, not 60.&lt;/p&gt;
&lt;p&gt;That is what the &lt;code&gt;Age&lt;/code&gt; header is for, and it is why intermediate caches are required to add it. When you are debugging a cache, &lt;code&gt;Age&lt;/code&gt; is the first thing to look at:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -sSI https://example.com/api/config \
  | grep -iE &apos;^(age|cache-control|etag|vary):&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The lifecycle a cache runs for every request is short:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Find a stored response whose request key and &lt;code&gt;Vary&lt;/code&gt; headers match.&lt;/li&gt;
&lt;li&gt;Compute its current age and compare against &lt;code&gt;max-age&lt;/code&gt; / &lt;code&gt;s-maxage&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If fresh, serve it and stop. The origin never hears about the request.&lt;/li&gt;
&lt;li&gt;If stale, revalidate with &lt;code&gt;If-None-Match&lt;/code&gt; or &lt;code&gt;If-Modified-Since&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On &lt;code&gt;304 Not Modified&lt;/code&gt;, refresh the stored response’s age and serve it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Step 5 is the part worth internalising. A &lt;code&gt;304&lt;/code&gt; is not a cache miss. It costs one round trip and zero bytes of body, and it resets the freshness clock.&lt;/p&gt;
&lt;h3&gt;Revalidation is cheap; recomputation is not&lt;/h3&gt;
&lt;p&gt;If you can produce an &lt;code&gt;ETag&lt;/code&gt; without doing the expensive work, revalidation is nearly free. If computing the &lt;code&gt;ETag&lt;/code&gt; requires rendering the whole response anyway, you have saved bandwidth and nothing else. That tradeoff decides whether validators are worth adding to a given endpoint.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const etag = `&quot;${hashOf(record.updatedAt, record.version)}&quot;`; // [!code highlight]

if (req.headers[&quot;if-none-match&quot;] === etag) {
  res.writeHead(304, { ETag: etag, &quot;Cache-Control&quot;: &quot;max-age=0&quot; });
  return res.end();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Serving stale on purpose&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;stale-while-revalidate&lt;/code&gt; decouples “how long this is fresh” from “how long I would rather serve something slightly old than make the user wait”. Inside the SWR window a cache serves the stale response immediately and refreshes it in the background.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;location /api/config {
  proxy_pass http://upstream;
  # [!code --]
  add_header Cache-Control &quot;public, max-age=60&quot;;
  # [!code ++]
  add_header Cache-Control &quot;public, max-age=60, stale-while-revalidate=600&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For anything read-mostly whose staleness costs nothing, that second line is close to free latency. The failure mode is real but narrow: a user can see up to &lt;code&gt;stale-while-revalidate&lt;/code&gt; seconds of old data after an update, so it does not belong on anything the user just wrote.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;stale-if-error&lt;/code&gt; is the sibling directive, and it is the one that earns its keep during an incident:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// [!code focus:2]
const CACHE_HEADER =
  &quot;public, max-age=60, stale-while-revalidate=600, stale-if-error=86400&quot;;

export function config(req, res) {
  res.setHeader(&quot;Cache-Control&quot;, CACHE_HEADER);
  res.setHeader(&quot;Vary&quot;, &quot;Accept-Encoding&quot;);
  res.json(readConfig());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Vary is where it breaks&lt;/h3&gt;
&lt;p&gt;A shared cache keys on the request URL plus whatever &lt;code&gt;Vary&lt;/code&gt; lists. Get that wrong and you either poison the cache or destroy your hit rate.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The “Vary” HTTP response header field describes what parts of the request message, aside from the method and target URI, might have influenced the origin server’s process for selecting the content of this response.&lt;/p&gt;
&lt;p&gt;— &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc9111#section-4.1&quot;&gt;RFC 9111, section 4.1&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Two rules cover most of it. &lt;code&gt;Vary: Accept-Encoding&lt;/code&gt; is almost always correct and almost always necessary. &lt;code&gt;Vary: Cookie&lt;/code&gt; is almost always a mistake: cookies are high-cardinality, so every distinct cookie value gets its own cache entry and the hit rate collapses toward zero. If a response genuinely depends on the user, mark it &lt;code&gt;private&lt;/code&gt; and let the browser cache it, instead of asking a shared cache to do something it cannot do safely.&lt;/p&gt;
&lt;h2&gt;A default worth starting from&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Immutable, content-hashed assets: &lt;code&gt;public, max-age=31536000, immutable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;HTML: &lt;code&gt;no-cache&lt;/code&gt; plus a strong &lt;code&gt;ETag&lt;/code&gt;, so a repeat navigation is a &lt;code&gt;304&lt;/code&gt; rather than a full download.&lt;/li&gt;
&lt;li&gt;Read-mostly JSON: &lt;code&gt;public, max-age=60, stale-while-revalidate=600&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Anything user-specific: &lt;code&gt;private, no-store&lt;/code&gt;, and stop thinking about it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pick from that list first, and reach for something more elaborate only when you can name the request that made it necessary.&lt;/p&gt;
</content:encoded></item></channel></rss>