diff --git a/docs/apis/subsystems/routing/caching.md b/docs/apis/subsystems/routing/caching.md
new file mode 100644
index 0000000000..6a598959b0
--- /dev/null
+++ b/docs/apis/subsystems/routing/caching.md
@@ -0,0 +1,78 @@
+---
+title: Caching
+tags:
+ - Routing
+ - Caching
+ - Performance
+---
+
+
+
+Routes which return static, public assets (such as language strings and templates) can be made cacheable by browsers and reverse proxies by setting the `cachelifetime` property of the `\core\router\route` attribute.
+
+## Declaring a cacheable route {/* #declaring-a-cacheable-route */}
+
+`cachelifetime` accepts any relative time string understood by [`DateInterval::createFromDateString()`](https://www.php.net/manual/en/dateinterval.createfromdatestring.php), for example `'90 days'`.
+
+```php title="A cacheable route"
+#[\core\router\route(
+ path: '/strings/{language}/{component}[/{identifier:.*}]',
+ method: ['GET'],
+ security: [],
+ cachelifetime: '90 days',
+ pathtypes: [
+ new \core\router\parameters\path_language(),
+ new \core\router\parameters\path_component(),
+ ],
+)]
+```
+
+:::danger
+
+Responses are cached as `public` and `immutable`. Only use `cachelifetime` for routes whose response is identical for every user and which never contains private data.
+
+:::
+
+## The cache key {/* #the-cache-key */}
+
+When `cachelifetime` is set, the route automatically gains an optional `cachekey` path parameter (`\core\router\parameters\path_cachekey`), which is a Unix timestamp placed at the start of the path:
+
+```text
+/api/rest/v2/strings/en/core # No cache key - not cached
+/api/rest/v2/cachekey:1737584047/strings/en/core # Cacheable
+```
+
+Because the cache key forms part of the URI, changing it (typically by using the current JavaScript/theme revision) invalidates every cached copy of the resource. You do not need to declare the parameter yourself.
+
+## Cache headers {/* #cache-headers */}
+
+The `\core\router\middleware\cache_control_middleware` adds the following headers when all of these are true:
+
+- the request method is `GET`;
+- the response status is 2xx;
+- the route has a `cachelifetime`;
+- a numeric `cachekey` was supplied, and it is not in the future (to prevent cache poisoning); and
+- the lifetime resolves to a positive number of seconds.
+
+| Header | Value |
+| --- | --- |
+| `Cache-Control` | `public, max-age=, immutable` |
+| `Expires` | The current time plus the lifetime |
+| `Pragma` | _(empty)_ |
+| `Accept-Ranges` | `none` |
+
+The expiry is calculated from the current time, not from the cache key, so cached content does not expire early because an old key was used.
+
+If any condition is not met, no caching headers are added.
+
+## Fetching cacheable routes from JavaScript {/* #fetching-cacheable-routes-from-JavaScript */}
+
+Pass the `cachekey` option to the [`core/fetch`](../../../guides/javascript/ajax/batcher.md) module:
+
+```js
+import Fetch from 'core/fetch';
+
+const response = await Fetch.performGet('core', `strings/en/core`, {cachekey: M.cfg.jsrev});
+```
+
+Requests with a cache key bypass the [batcher](../../../guides/javascript/ajax/batcher.md) by default, so that every URL can be cached individually.
diff --git a/docs/guides/javascript/ajax/batcher.md b/docs/guides/javascript/ajax/batcher.md
new file mode 100644
index 0000000000..a3d3e781de
--- /dev/null
+++ b/docs/guides/javascript/ajax/batcher.md
@@ -0,0 +1,81 @@
+---
+title: The Fetch batcher
+tags:
+ - Javascript
+ - AJAX
+ - Fetch
+ - Routing
+---
+
+
+
+The `core/fetch` module makes requests to the Moodle [REST API (routes)](../../../apis/subsystems/routing/index.md). In addition to immediate requests, it can queue several requests and send them as a single HTTP `$batch` request, reducing the number of round trips.
+
+## Immediate requests {/* #immediate-requests */}
+
+Static methods perform a single request immediately:
+
+```js
+import Fetch from 'core/fetch';
+
+const response = await Fetch.performGet('mod_example', 'animals', {params: {type: 'mammal'}});
+```
+
+`performGet`, `performHead`, `performPost`, `performPut`, `performPatch` and `performDelete` are available.
+
+## Batching requests {/* #batching-requests */}
+
+The same methods are available on an _instance_. Instance calls are queued and sent together.
+
+### The automatic batcher {/* #the-automatic-batcher */}
+
+`Fetch.getBatcher()` returns a shared singleton which automatically executes the queue 50ms after the last request was added.
+
+```js
+import Fetch from 'core/fetch';
+
+const batcher = Fetch.getBatcher();
+const [mammals, reptiles] = await Promise.all([
+ batcher.performGet('mod_example', 'animals', {params: {type: 'mammal'}}),
+ batcher.performGet('mod_example', 'animals', {params: {type: 'reptile'}}),
+ batcher.performDelete('mod_example', `animals/${pig.id}`),
+]);
+```
+
+Each call returns a `Promise` resolving to a standard `Response` for that sub-request, and failures reject only the individual promise.
+
+### A manual batcher {/* #a-manual-batcher */}
+
+Create an instance yourself and call `execute()` when ready. An optional constructor argument sets an auto-execute delay in milliseconds.
+
+```js
+const batcher = new Fetch();
+const results = Promise.all([
+ batcher.performGet('mod_example', 'animals', {params: {type: 'mammal'}}),
+ batcher.performGet('mod_example', 'animals', {params: {type: 'reptile'}}),
+]);
+
+batcher.execute();
+await results;
+```
+
+## Behaviour notes {/* #behaviour-notes */}
+
+- If a batch contains a single request, the batch endpoint is _not_ used; it is sent as a normal request.
+- The queue is flushed automatically once it holds more than 20 requests.
+- If the `$CFG->batch_fetch_requests` setting (JS: `M.cfg.batchFetchRequests`, default `true`) is disabled, each request is executed immediately instead of being queued.
+
+## Cacheable requests bypass the batcher {/* #cacheable-requests-bypass-the-batcher */}
+
+Requests made with a `cachekey` (see [route caching](../../../apis/subsystems/routing/caching.md)) are sent individually, even via a batcher. Hiding them inside a generic `$batch` request would prevent browsers and reverse proxies caching each URL.
+
+```js
+const response = await Fetch.getBatcher().performGet('core', 'strings/en/core', {cachekey: M.cfg.jsrev});
+```
+
+To batch these requests anyway, set `$CFG->batch_api_asset_requests = true` (JS: `M.cfg.batchApiAssetRequests`).
+
+## See also {/* #see-also */}
+
+- [AJAX](./index.md)
+- [Routing: Caching](../../../apis/subsystems/routing/caching.md)