---
title: Caching
source: 'https://academia.sh/en/courses/web-application-framework/caching'
course: 'The Web Application Framework'
language: en
updated: '2026-10-21T05:03:38+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Separates the key, the time to live and invalidation'
    - 'Measures that the computation is skipped on a cache hit and runs after invalidation'
    - 'Shows that the response carries a stale value when invalidation is forgotten and when the time to live corrects it'
---

# Caching

The key-based cache, the time to live, the remember function and invalidation; the computation being skipped on a hit and running after invalidation; recomputation when the time to live ends, and the current record not reaching the response when invalidation is forgotten.

In the Notifications lesson the notification channels ran again on every request; every request had to ask the same question from the beginning. If computing a value is expensive and the value does not change often, it is cheaper to store the result and skip the computation on the second request. This lesson asks: **when the record changes, does the stored value change too?**

A **cache** is a place that stores a value under a **key**. The **time to live** says how long the stored value stays valid; when the time ends, the value is stale and is computed again. The function at the center of the lesson is `remember(key, ttl, compute)`: if the key is valid in the cache, it returns the value and does not call the `compute` function (a **hit**); if not, it calls it, stores the result and returns it. Deleting the stored value when the record changes is called **invalidation**. A cache gives speed, but it also brings the risk of staleness, and the risk becomes concrete where invalidation is forgotten.

**AS5.** The cache is an array; it is not a server, a network or a file. The clock is a variable (`$now`); `sleep` is not called, and the passage of time is imitated by increasing this variable, so the measurement does not depend on real time. The time to live counts as ended exactly at the boundary: when the expiry moment arrives, the entry is no longer valid. Cache consistency, two simultaneous requests overwriting each other, and a size limit are not modeled.

## The Measurement Core

The same measurement is used in every lesson of the course. `ran.php` records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. `Ledger` keeps three lists: the parts placed on the request's path, the parts called within the request, and the parts left outside the request. `classify()` checks them in order. **The decision order is `classify()` itself:** ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request.

```php
<?php
// ran.php — did an expected part run in this request?
declare(strict_types=1);

final class Ledger {
    public static array $path = [];        // parts placed on the request's path
    public static array $ran = [];         // parts called within the request
    public static array $deferred = [];    // parts left outside the request
    public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; }
    public static function onPath(string $part): void { self::$path[] = $part; }
    public static function ran(string $part): void { self::$ran[] = $part; }
    public static function defer(string $part): void { self::$deferred[] = $part; }
}

function classify(string $part): string {
    return match (true) {
        in_array($part, Ledger::$ran, true) => 'ran',
        in_array($part, Ledger::$deferred, true) => 'deferred',
        in_array($part, Ledger::$path, true) => 'skipped',
        default => 'not wired',
    };
}
```

In this lesson the part that is measured is `compute`, the computation function given to the cache. `remember()` places it on the path on every call; if there is a hit, the function is not called and the part stays under **skipped**; if there is no hit, it is marked with `Ledger::ran()`. A second part, `current`, measures in the last section whether the response carries the current record in the store.

## The Cache, the Time to Live and Invalidation

`cache.php` is the whole slice. `Store` is a reduced form of the persistent record: an array from post id to title, and it is the source of truth. `Cache` keeps one value and one expiry moment per key. `remember()` places the computation on the path and looks at the entry; if the expiry moment is greater than now, it returns the value; if not, it calls the function and stores the value with the expiry `now + ttl`. `forget()` deletes the entry, which is to say it invalidates. `PostsController::show()` gets the title through `remember()` and counts how many times it was really computed. `isCurrent()` is a measurement helper: if the title in the response is the same as the one in the store, it places the `current` part on the path and calls it; if not, it does nothing.

```php
<?php
// cache.php — the key-based cache, the time to live, invalidation
declare(strict_types=1);
require_once 'ran.php';

final class Store {                         // the persistent record: the source of truth
    public static array $title = [7 => 'Old title'];
}

final class Cache {
    public int $now = 0;                    // the clock is a variable; sleep is not called
    private array $entries = [];            // key => [value, expiry]

    public function remember(string $key, int $ttl, callable $compute): mixed {
        Ledger::onPath('compute');
        $entry = $this->entries[$key] ?? null;
        if ($entry !== null && $entry[1] > $this->now) {
            return $entry[0];               // hit: the computation is not called
        }
        Ledger::ran('compute');
        $value = $compute();
        $this->entries[$key] = [$value, $this->now + $ttl];
        return $value;
    }

    public function forget(string $key): void {   // invalidation
        unset($this->entries[$key]);
    }
}

final class PostsController {
    public static int $computations = 0;
    public function __construct(private Cache $cache) {}
    public function show(int $id): array {
        Ledger::newRequest();
        Ledger::onPath('show');
        Ledger::ran('show');
        $title = $this->cache->remember("post.$id", 60, function () use ($id) {
            self::$computations++;
            return Store::$title[$id];
        });
        return [200, $title];
    }
}

function isCurrent(array $response, int $id): void {   // does the response carry the current record in the store
    if ($response[1] === Store::$title[$id]) { Ledger::onPath('current'); Ledger::ran('current'); }
}
```

## The Computation Was Skipped on a Hit

The first measurement makes three requests. In the first the cache is empty. The second comes ten units later and is still within the time. The third comes after the post's title has been changed in the store and the cache entry has been invalidated.

```php
<?php
// hit.php — the first request, a hit, invalidation
require_once 'cache.php';

$cache = new Cache();
$controller = new PostsController($cache);

function request(string $label, PostsController $c, Cache $k): void {
    $response = $c->show(7);
    printf("%s (time %d) -> %d %s\n", $label, $k->now, $response[0], $response[1]);
    echo '  compute: ', classify('compute'), ', total computations: ', PostsController::$computations, "\n";
}

request('first request', $controller, $cache);
$cache->now = 10;
request('second request', $controller, $cache);
Store::$title[7] = 'New title';
$cache->forget('post.7');
$cache->now = 20;
request('after invalidation', $controller, $cache);
```

```
first request (time 0) -> 200 Old title
  compute: ran, total computations: 1
second request (time 10) -> 200 Old title
  compute: skipped, total computations: 1
after invalidation (time 20) -> 200 New title
  compute: ran, total computations: 2
```

This lesson's observation is in the second request. The cache entry was valid, `remember()` returned the value, and `compute` was placed on the path but not called: **skipped**. The total number of computations stayed at 1. The response carried the same title, `Old title`; the client did not see the difference, but the work was not done. In the first request the same `compute` **ran**: the cache was empty, the computation was called, the result was stored and the total became 1.

The third request is the contrast. The title was changed in the store and `forget('post.7')` deleted the entry. The response carried `New title`, `compute` **ran** again and the total number of computations became 2. The same key, the same controller; only the presence of the entry decided whether the computation would run. Invalidation is the only way to tell the cache about a change.

## The Bounding Measurement

What happens if invalidation is forgotten, and does the time to live correct it? The second measurement makes three requests. The first stores the value. Then the title changes in the store, but the entry is not deleted. The second request comes before the time ends. The third comes when the time has ended. In every request the `current` part measures whether the response carries the current record in the store.

```php
<?php
// stale.php — when the time to live ends and when invalidation is forgotten
require_once 'cache.php';

$cache = new Cache();
$controller = new PostsController($cache);

function measure(string $label, PostsController $c, Cache $k): void {
    $response = $c->show(7);
    isCurrent($response, 7);
    printf("%s (time %d) -> %s\n", $label, $k->now, $response[1]);
    echo '  compute: ', classify('compute'), ', current record: ', classify('current'), "\n";
}

measure('store in cache', $controller, $cache);
Store::$title[7] = 'New title';             // invalidation was forgotten
$cache->now = 30;
measure('before the time ends', $controller, $cache);
$cache->now = 60;
measure('after the time ends', $controller, $cache);
echo 'total computations: ', PostsController::$computations, "\n";
```

```
store in cache (time 0) -> Old title
  compute: ran, current record: ran
before the time ends (time 30) -> Old title
  compute: skipped, current record: not wired
after the time ends (time 60) -> New title
  compute: ran, current record: ran
total computations: 2
```

In the first request `compute` **ran**, and because the response carried the record in the store, `current` ran too. Then the title changed in the store and the entry was not deleted. In the second request, at time 30, the cache returned the valid entry: `compute` was **skipped** and the response carried `Old title`, which means the `New title` in the store did not reach the response. For this reason the `current` part never entered the path and is **not wired**. The response is 200 and there is no error; the stale value returns silently. This is the most dangerous form of a cache: the response looks successful, and what is missing is indicated nowhere.

The third request came at time 60, and the time to live had ended exactly. `compute` **ran** again, the response carried `New title` and `current` ran too. The total number of computations is 2. The time to live corrected the staleness, but only at the end of the time. The longer the time, the greater the cost of a forgotten invalidation. Invalidation and the time to live do not replace each other: invalidation reflects the change immediately, and the time to live is an upper bound against a forgotten invalidation.

## Summary

- This lesson's outcome is **skipped**: in the second request the cache hit, `compute` was on the path and was not called, and the total number of computations stayed at 1.
- `remember(key, ttl, compute)` returns a valid entry, and if there is none it computes and stores; invalidation deletes the entry.
- Its contrast: after invalidation `compute` **ran** again, the response carried the new title and the total became 2.
- In the bounding measurement `compute` **ran** again when the time to live ended; when invalidation was forgotten, the current record was **not wired** to the response and the stale value returned with 200.
- The time to live does not replace invalidation; it only limits how long a forgotten invalidation stays stale.

## Next Step

The value in the cache stayed stale as long as it did not know about the change at its source. What if the stored thing is not a value but a file, and the disk the file is written to changes: what changes, the upload code, the response, or the file itself? The File Storage lesson writes and reads a file on two separate disks and measures which of them changes when the disk changes.
