Caching
What Takumi keeps between renders, which renderer keeps it, and how to size, inspect, skip, or share it.
Takumi keeps decoded images, fonts, glyphs, and parsed styles between renders, so content that repeats costs less the second time. It keeps them in two layers. Each renderer keeps decoded work in its own memory, and a store keeps the bytes it downloads. This page covers both layers, how to size or skip each one, and how to see what a renderer holds.
What a renderer keeps
A renderer keeps these kinds of work in one pool of memory, 128 MiB by default:
| Work | budgets key |
|---|---|
| Decoded images and the rasters drawn from them | images |
| SVG images laid out to drawing ops | pictures |
| Parsed stylesheets | stylesheets |
| Compiled Tailwind classes | tailwind |
| Glyph outlines and rasterized masks | glyphs |
Where glyphs cross a text-decoration line | intercepts |
Faces that @font-face rules add | fontFaces |
Font files that renders and registerFont load | fonts |
Every render on that renderer reads from the pool and adds to it. When the pool is full, the renderer evicts the entries renders have not reused, whatever their kind, and a later render that needs one does the work again. A page heavy with images can use most of the pool, and a page of CJK text can fill it with glyphs, without either needing a setting.
The pool counts the estimated size of each entry, not the memory of the whole process. It does not count active renders, output buffers, or allocator overhead.
See Performance for measuring memory and render time.
The bytes a renderer downloads sit outside the pool, in its download store.
Which renderer owns a cache
render, renderSvg, renderAnimation, measure, and ImageResponse share one renderer, which takumi-js creates the first time you call any of them. Its pool lives for the life of the process.
A Renderer, PdfRenderer, or Painter you create keeps a pool of its own. Create it once, outside the request handler, so what it keeps survives between renders. Pass it to ImageResponse with the renderer option.
Set a renderer's cache size
Pass cache.maxBytes when you create the renderer:
import { } from "takumi-js/node";
const = new ({
: { : 256 * 1024 * 1024 },
});Raise maxBytes when renders keep redoing work that earlier renders did, such as decoding the same images. A larger pool helps only when later renders reuse what it keeps, so benchmark your own content first. A maxBytes of 0 keeps nothing in the pool between renders, except the fonts from registerFont, as Release fonts describes.
PdfRenderer and Painter take the same cache option. Its store key sets the download store, which maxBytes does not cover.
Give one kind a budget
A budget caps how many bytes one kind of work may hold. Most renderers need none. Set one when you measured that one kind pushes out the others, such as a route that draws a different large photo on every request and evicts the glyphs every render reuses:
import { } from "takumi-js/node";
const = new ({
: {
: 256 * 1024 * 1024,
: { : 64 * 1024 * 1024 },
},
});A kind with a budget evicts only its own entries to stay within the budget, so other kinds cannot push them out. The bytes it holds still count against maxBytes, so the pool shrinks as the kind fills its budget, and a budget the kind does not use stays available to the other kinds. A budget of 0 keeps none of that kind between renders.
Budgets never hold more than maxBytes together. Once the kinds with budgets fill it, a kind that needs more evicts its own entries instead of growing.
Change the cache of a running renderer
A renderer's setCacheOptions method replaces its cache options while it keeps running:
import { } from "takumi-js/node";
const = new ();
.({ : 32 * 1024 * 1024 }); It takes the same options as cache in the constructor, and replaces all of them. A key you leave out returns to its default, so the call above also removes any budget the renderer had, and puts back the renderer's own download store if you had replaced it. That store comes back with what it held. A smaller size or budget evicts what no longer fits right away. Changing only a size keeps what still fits. A kind that gains or loses a budget starts empty, and renders that are running when you call it finish with the entries they started with. PdfRenderer and Painter have the same method.
The shared renderer
setCacheOptions from takumi-js sets the cache of the renderer that render and the other functions share:
import { } from "takumi-js";
({ : 256 * 1024 * 1024 }); You can call it at any time. Once the shared renderer exists, the call applies to it right away, as the renderer's own setCacheOptions does. Before then, the renderer takes the options when it is created.
See what the cache holds
A renderer's cacheStats method reports what its caches hold and how often renders found what they looked up:
import { } from "takumi-js/node";
const = new ();
const { , , } = .();
.(., ., .);
.(.., .., ..);
.(?., ?., ?.);memory covers the pool that every kind shares. current is the bytes it holds now, budgets included. high is the most it held at once since the renderer was created, and max is its maxBytes.
budgets reports each kind under its budgets key, whether the kind has a budget or shares the pool. Each entry has these fields:
currentis the bytes the kind holds now.maxis the kind's budget, ornullwhen it has none.hitscounts lookups that found an entry, andmissescounts lookups that had to do the work again.evictionscounts entries released to stay withinmaxBytesor the budget.
The fonts entry counts the decoded font files the renderer holds, fonts from registerFont included. All sizes are the same estimates the budgets count, not the memory of the process, and the counts start when the renderer is created.
store reports the download store with the same fields. hits and misses count the lookups this renderer made, and a lookup that found bytes its allowUrl refused counts as a miss. A store that does not report its size, as yours may not, has null for current, max, and evictions. store is null when the renderer keeps no downloads.
For the renderer that render and the other functions share, call cacheStats from takumi-js. It returns undefined until the first render creates that renderer:
import { } from "takumi-js";
const = ();
.(?..);A high miss count with many evictions means renders keep redoing work the cache dropped, so a larger maxBytes may help. A kind that holds most of memory.current while its hits stay low is a candidate for a budget.
Skip caching an image
Every image source takes a cache mode, which decides whether the renderer keeps its decoded pixels:
auto, the default, keeps the decoded image in the renderer's pool.noneuses an image the cache already holds, but never adds one.
Use none for an image shown once, so it does not push out images worth keeping:
import { } from "takumi-js/response";
export function (: ) {
return new (< ="banner" />, {
: [
{
: "banner",
: () => (new ("/banner.png", .)).(() => .()),
: "none",
},
],
});
}To set the mode for every image in a render, pass images as an object instead of an array. The mode then covers the images Takumi fetches itself too, and a source's own cache still wins. A route that draws a different avatar on every request can keep the avatars out of the cache while its logo stays in:
import { } from "takumi-js/response";
export function (: ) {
const = new (.)..("avatar") ?? "";
return new (< ={} />, {
: {
: "none",
: [
{
: "logo",
: () => (new ("/logo.png", .)).(() => .()),
: "auto",
},
],
},
});
}
function ({ }: { : string }) {
return (
<>
< ="logo" />
< ={} />
</>
);
}Takumi fetches the avatar URL itself, so it takes the render's none. The logo's own auto keeps it cached.
The decoded-image cache is keyed by a hash of the image's bytes, so the same image under two URLs decodes once. A data: URI in an <img> or a CSS url() always caches in auto mode, because the cache mode applies only to sources and to URLs Takumi fetches. A template that inlines its logo as a data: URI decodes it once.
Downloaded bytes
Each renderer keeps the bytes it downloads in a store, keyed by the URL it requested. That covers the images it fetches, the files @font-face rules load, and the fonts you list as URLs. A later render that needs one of them reads it from the store and skips the download, even after the pool evicted the decoded copy.
By default, every renderer has a store of its own that holds up to 32 MiB. When it is full, it drops the downloads that renders used least recently. A failed download never enters it. Renders on the same renderer share the store, and the renderer that render and the other functions share keeps its store for the life of the process.
The store keeps downloads that resources with cache: "none" use too, since that mode only skips the decoded copy. The store's size does not count toward maxBytes, and its sizes are estimates that count the bytes and the URLs. The decoded copy of the same image or font counts in the pool as usual.
Renders that ask for the same URL at the same time share one download when their allowUrl is the same function and their maxBytes match. Each render's signal and timeout end only its own wait, and the download stops once no render waits on it.
A stored download remembers the URL it came from after redirects. A render uses it only when its own allowUrl accepts that URL. Otherwise, the render downloads it again under its own policy and leaves the stored copy for other renders. Routes with different URL policies can share a renderer.
A render that passes its own fetch in images skips the default store, since that fetch may send credentials or answer a relative URL differently for each user. Its downloads go through a store only when you passed one as cache.store.
A store keys downloads by URL alone. If you pass a store and your fetch sends credentials, give
each user's route a renderer with a store of its own, or none.
Size or replace the store
Pass a MemoryStore with its own maxBytes as cache.store, or null to keep no downloads:
import { } from "takumi-js/node";
import { } from "takumi-js/helpers";
const = new ({
: { : new ({ : 128 * 1024 * 1024 }) },
});
const = new ({ : { : null } });Pass the same store to two renderers to share their downloads. setCacheOptions replaces the store of a running renderer, as it does the other keys.
A store is any object with get and set methods. get(url) returns the stored { bytes, url } or undefined, and set(url, download) offers a download to keep. Either may return a promise. The store decides what to keep and when to drop it, and Takumi treats a store that throws as one that holds nothing. To report its size in cacheStats, add a usage() method that returns { current, max, evictions }.
On Cloudflare Workers, the Cache API keeps downloads across isolates in one data center:
import type { DownloadStore } from "takumi-js/helpers";
const cache = await caches.open("takumi-downloads");
export const store: DownloadStore = {
async get(url) {
const response = await cache.match(url);
return (
response && { bytes: await response.arrayBuffer(), url: response.headers.get("x-url") ?? url }
);
},
async set(url, download) {
const headers = { "x-url": download.url, "cache-control": "max-age=86400" };
await cache.put(url, new Response(download.bytes, { headers }));
},
};On Node.js, a directory keeps downloads across restarts. This one never drops a file, so clear the directory on your own schedule:
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import type { DownloadStore } from "takumi-js/helpers";
const directory = "/var/cache/takumi";
const pathOf = (url: string) => `${directory}/${createHash("sha256").update(url).digest("hex")}`;
export const store: DownloadStore = {
async get(url) {
try {
const [bytes, finalUrl] = await Promise.all([
readFile(pathOf(url)),
readFile(`${pathOf(url)}.url`, "utf8"),
]);
return { bytes: new Uint8Array(bytes).buffer, url: finalUrl };
} catch {
return undefined;
}
},
async set(url, download) {
await mkdir(directory, { recursive: true });
await writeFile(pathOf(url), new Uint8Array(download.bytes));
await writeFile(`${pathOf(url)}.url`, download.url);
},
};fetchCache
images.fetchCache is deprecated. It still works, and a render that passes one keeps its downloads there instead of in the renderer's store. Downloads Takumi writes to it now remember where a redirect led, so a hit passes the same allowUrl check as the store's.
Release fonts
A renderer keeps each font file a render loads in its pool, as one entry the size of the decoded file. A file counts once, however many renders, keys, or families load it. When the pool or the fonts budget needs room, the renderer evicts the files renders have not reused, one file at a time, and a later render that lists one loads it again. This applies to fonts from a render's fonts list and to files that @font-face rules load.
Set the fonts budget to cap these files apart from the other kinds:
import { } from "takumi-js/node";
const = new ({ : { : { : 64 * 1024 * 1024 } } }); A render holds every font file it uses until it finishes, whether it succeeds or fails, so another render cannot evict them. While renders hold more than the budget, the renderer keeps them all, and it evicts what no longer fits once they finish. With a budget or maxBytes of 0, no font a render loaded stays after that render.
A font with cache: "none" leaves once the render that loaded it settles.
Fonts from registerFont always stay. They count toward maxBytes and the fonts budget, so they leave that much less room for the rest. A prepared render loads any @font-face file the renderer evicted after prepare. To release fonts yourself, see Releasing fonts.
Google Fonts downloads
googleFonts keeps its CSS responses and the font files its subsets download in a store of its own, since it runs before any renderer sees the fonts. By default, that is one store of up to 32 MiB for each fetch function, shared by every call in the process. Its store option takes a MemoryStore, your own store, or null, as cache.store does. A request with signal or allowUrl uses the store too, under the same rules as a renderer's. maxBytes applies to stored responses as well.
In Rust
The takumi crate keeps the same pool in a Cache. Build one with its own size, and pass it to each render that should share it:
use takumi::prelude::*;
let cache = Cache::builder()
.max_bytes(256 << 20)
.images(64 << 20)
.build();
let options = RenderOptions::builder()
.viewport(Viewport::new((1200, 630)))
.fonts(&fonts)
.node(node)
.cache(cache.clone())
.build();max_bytes sets the pool in bytes. Each budgets key has a setter of the same name in snake case, such as images, stylesheets, or font_faces, which gives that kind a budget in bytes. Cache::set_options takes a CacheOptions with the same fields and applies it to a cache in use, as setCacheOptions does. Cache::stats returns a CacheStats with the fields cacheStats reports, except fonts. The fonts budget belongs to the JavaScript renderers, so Cache has no setter for it. A render without cache uses Cache::shared(), one 128 MiB cache for the whole process. Cache::decode_image and Cache::parse_stylesheet decode images and parse CSS through a cache before a render, as the JavaScript renderers do.
Last updated on