Skip to content
MINH VO A working notebook
by an engineer in Vietnam
Foundation9 min read

TTL cache freshness depends on when you start the clock

Compare selection-time and arrival-time TTLs, test exact expiration boundaries, and carry response age through an explicit HTTP caching example.

Three miniature block-built clusters connected by channels and small wooden balls

A ten-second TTL can mean “reuse this value for ten seconds after the load finishes.” It can also mean “reuse it only until ten seconds after the source was observed.” Those policies give different answers when loading takes time. You need to choose the event that starts the clock before treating TTL as a freshness bound.

This lesson is for application engineers who can follow a short Python function and want to review a cache’s expiration rule. We will use an invented event timeline and integer ticks, not measured service latency. The first model assumes that a load selects the latest source value at its start and returns that exact value later. That assumption makes the age calculation testable; an asynchronously replicated source needs a stronger contract before the same conclusion holds.

Separate selection, arrival and update

Consider one configuration key. At time 0, a read selects value old. At time 2, the authoritative source commits new. The outstanding read still returns old at time 8. Such a sequence is possible whenever the result is fixed before the response finishes traveling or being processed.

A TTL of ten ticks measured from arrival produces a deadline of 18. Measured from selection, it produces a deadline of 10. At time 13, the arrival-based policy permits the old value, even though thirteen ticks have passed since it was selected. The selection-based policy requires a new load or another explicit action.

Neither policy knows about the update at time 2. At time 9, both permit old, although new already exists. Expiration is a reuse rule; it is not a notification from the source. A still-valid cache entry can differ from the current authoritative value. That distinction matters when someone describes a cache as “fresh”: ask whether they mean within its permitted age or equal to the latest committed state.

A load selects old at zero, the origin changes at two, and the result arrives at eight; the two expiration deadlines are ten and eighteen.View full-size image ↗

The eight-tick load interval consumes part of a selection-based budget. Starting a new budget at arrival gives the same selected value eight extra ticks of permitted reuse.

A small executable expiration rule

The following Python 3 program calculates eligibility for one already-loaded value. All inputs are nonnegative built-in integers in one artificial time coordinate. Booleans are rejected even though Python treats them as an integer subtype. We construct Window through ttl_window; direct construction is outside this small example’s validated interface.

The returned window records when the result arrived and its expiration deadline. Looking up a time before arrival is an error: there is no value to serve yet. At the deadline itself, the value is expired. This strict comparison avoids adding an accidental extra tick at every boundary.

from dataclasses import dataclass


def tick(value):
    if type(value) is not int or value < 0:
        raise ValueError("use nonnegative integer ticks")
    return value


@dataclass(frozen=True)
class Window:
    received: int
    expires: int

    def usable(self, now):
        tick(now)
        if now < self.received:
            raise ValueError("the value has not arrived yet")
        return now < self.expires


def ttl_window(start, finish, ttl, *, from_completion=False):
    for value in (start, finish, ttl):
        tick(value)
    if finish < start or type(from_completion) is not bool:
        raise ValueError("invalid load interval or policy")
    base = finish if from_completion else start
    return Window(finish, base + ttl)


from_start = ttl_window(0, 8, 10)
from_finish = ttl_window(0, 8, 10, from_completion=True)
assert (from_start.expires, from_finish.expires) == (10, 18)
assert from_start.usable(9) and from_finish.usable(9)
assert not from_start.usable(13) and from_finish.usable(13)
assert not from_start.usable(10)
assert not from_finish.usable(18)
assert not ttl_window(0, 12, 10).usable(12)
for policy in (False, True):
    assert not ttl_window(0, 8, 0, from_completion=policy).usable(8)
print("deadlines:", from_start.expires, from_finish.expires)

Run the later block in the same file. These checks are deterministic arithmetic examples, so no sleep or wall-clock adjustment is needed. usable only answers an age-policy question. It does not return data, remove an entry, contact the source, or decide how a failed reload should affect the caller.

The long-load assertion deserves attention. If selection happens at 0 and arrival at 12, a ten-tick selection budget is already exhausted. Serving the result anyway would change the contract. An application might retry, return an error, validate a version, or explicitly permit older data. Repeatedly retrying a source whose loads always exceed the budget can make the system unavailable without ever producing an eligible entry. A smaller TTL is therefore not a free improvement.

A zero TTL permits no reuse, including at the instant of arrival. It does not mean “never expire.” Some real cache APIs assign special meanings to zero, so the interface contract must be checked before translating this model into a library call.

State the bound you can actually prove

Let s be selection time, f be arrival time, T be the allowed age, and n be the observation time. We only consider observations at or after arrival. The load duration is D = f - s.

For selection-based expiration, eligibility means n < s + T, or equivalently n - s < T. Every accepted value is younger than T ticks since that selection. For arrival-based expiration, eligibility means n < f + T; substituting f = s + D gives n - s < D + T. The latter is a valid reuse policy, but it does not establish the former age bound when the load duration is positive.

Starting the timer before the source snapshot is taken can be conservative: time spent waiting for the read also consumes the budget. Starting it after a snapshot has already become old can be optimistic. Neither a function’s name nor a field called created_at tells you which event it represents.

The proof says nothing about how recently the source itself learned the value. If a replica returns data that was already stale before time 0, measuring from 0 hides that earlier age. The latest-at-selection assumption is doing real work. To bound age across several caches or replicas, preserve meaningful age or validation information from upstream and define what that information measures.

Why HTTP carries age across caches

HTTP caching makes this distinction explicit. RFC 9111 section 4.2 compares a response’s current age with its freshness lifetime; merely receiving a response does not generally make its age zero. Section 4.2.3 describes the Age field as an estimate of time since the origin generated or validated the response, including cache residence and transit.

Here is a separate hypothetical HTTP calculation. Our request starts at 100 and its response arrives at 108. The response’s Date is 105 and its Age value is 4. The apparent age is three seconds; the supplied age plus this request’s response delay is twelve seconds. We use the conservative maximum from section 4.2.3, giving an initial age of twelve seconds. The RFC also permits using the corrected age value directly in the circumstances it describes; the maximum is the policy demonstrated here.

With a freshness lifetime of ten seconds, this response is already stale on arrival. If the lifetime is fifteen seconds instead, it has three seconds left until its age reaches fifteen. Those are alternatives for the same arithmetic example, not two headers applied to one response.

HTTP age calculation with request time one hundred, response time one hundred eight, Date one hundred five, and Age four; initial age is twelve and reaches fifteen three seconds later.View full-size image ↗

The figure tracks an estimated response age. It does not claim that a local TTL can recover an unknown source update time.

This helper implements only that numerical calculation. All times are already parsed nonnegative integer seconds in compatible coordinates; request, response and observation are ordered. It does not parse headers, select a cache key, evaluate storage permissions, process validators, or implement an HTTP cache.

def http_age(request, response, date, age, now, lifetime):
    for value in (request, response, date, age, now, lifetime):
        tick(value)
    if not request <= response <= now:
        raise ValueError("request, response and observation must be ordered")
    apparent = max(0, response - date)
    corrected = age + response - request
    initial = max(apparent, corrected)
    current = initial + now - response
    return initial, current, current < lifetime


assert http_age(100, 108, 105, 4, 108, 10) == (12, 12, False)
assert http_age(100, 108, 105, 4, 110, 15) == (12, 14, True)
assert http_age(100, 108, 105, 4, 111, 15) == (12, 15, False)
print("HTTP age:", http_age(100, 108, 105, 4, 111, 15))

The freshness lifetime is an input here. A real HTTP cache must derive it under section 4.2.1, which distinguishes shared-cache s-maxage, max-age, Expires, and applicable heuristic rules. The response’s ability to be reused also depends on other directives. Section 4.2.4 places conditions on serving stale responses. Our Boolean is a freshness calculation, not a complete permission decision for sending a response.

Pick a clock with the right meaning

For local elapsed intervals, Python’s time.monotonic() cannot go backward and is unaffected by system-clock updates. monotonic_ns() returns integer nanoseconds, which avoids float precision loss. A real local implementation can capture start, finish and lookup times from that same clock and express its TTL in matching units.

Its reference point is undefined. Do not subtract an HTTP Date timestamp from a monotonic reading and call the result apparent age. Calendar timestamps and elapsed intervals need an explicit conversion strategy; values from unrelated clock origins are not interchangeable. Likewise, do not persist a bare local deadline and assume it remains meaningful after an environment change. The numeric examples keep clock domains explicit so that the policy can be examined independently of platform clock behavior.

Expiration also does not imply immediate memory reclamation. A cache may check the deadline when a key is accessed, sweep expired entries separately, or combine both. That affects storage use and cleanup work. The promise relevant to this lesson is whether the read path serves an expired entry, regardless of whether its bytes remain allocated.

Review one boundary before choosing a TTL

For an exercise, change the load duration from eight to twelve while keeping the age budget at ten. Predict the first possible eligible observation under both policies. The selection-based policy has none for that returned result; the arrival-based policy permits observations from twelve up to, but not including, twenty-two. For a separate scenario, restore the load duration to eight, let the source update at time 1, and observe the cache at time 9. Both policies then permit the outdated value within their allowed intervals.

When reviewing a cache, name the event that fixes the value, the event that starts the budget, the clock used to measure it, and the action taken when the budget is exhausted. If upstream data can already be old, add that fact to the contract. Those choices determine what a TTL promises; the number of seconds only fills in the chosen rule.

Sources & further reading

  1. RFC 9111: HTTP Caching, freshness and age
  2. Python: time.monotonic and time.monotonic_ns
← Back to the journal
All notes

Illustration

100%