Takumi

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 you can give Takumi a store for 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:

Workbudgets key
Decoded images and the rasters drawn from themimages
SVG images laid out to drawing opspictures
Parsed stylesheetsstylesheets
Compiled Tailwind classestailwind
Glyph outlines and rasterized masksglyphs
Where glyphs cross a text-decoration lineintercepts
Faces that @font-face rules addfontFaces

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:

  • Fonts that renders' fonts lists load, which have their own fonts budget.
  • Fonts from registerFont.
  • Active renders, output buffers, and allocator overhead.

See Performance for measuring memory and render time.

Three more caches sit outside the renderer:

  • Downloaded image bytes, in a fetchCache you pass in.
  • Emoji images, up to 8 MiB for each fetch function.
  • Google Fonts CSS responses, up to 1 MiB and 64 responses for each fetch function.

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:

Cache size
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. Fonts are counted apart, as Release fonts describes.

PdfRenderer and Painter take the same cache option.

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:

Cap the images
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. The fonts budget works apart from the pool, as Release fonts describes.

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:

Shrink the cache at runtime
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. 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:

Shared renderer cache
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:

Read a renderer's cache stats
import {  } from "takumi-js/node";

const  = new ();

const { ,  } = .(); 

.(., ., .);
.(.., .., ..);

memory covers the pool that every kind except fonts 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:

  • current is the bytes the kind holds now.
  • max is the kind's budget, or null when it has none.
  • hits counts lookups that found an entry, and misses counts lookups that had to do the work again.
  • evictions counts entries released to stay within maxBytes or the budget.

The fonts entry counts the fonts that renders' fonts lists loaded. It leaves out fonts from registerFont, since the budget never releases them. All sizes are the same estimates the budgets count, not the memory of the process, and the counts start when the renderer is created.

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:

Read the shared renderer's cache stats
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.
  • none uses 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:

Skip caching one image
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:

Cache the logo, skip the avatars
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 image bytes

The renderer keeps decoded pixels, not the bytes Takumi downloads. To reuse downloaded bytes and pending requests across renders, pass a fetchCache:

Share downloaded image bytes
import {  } from "takumi-js";

const  = < ="https://example.com/image.png" />;

const  = new <string, <>>();

const  = await (, {
  : {
    , 
  },
});

fetchCache takes a Map<string, Promise<ArrayBuffer>> or any object with the same get, set, and delete methods. It is keyed by URL, and its values are promises, not raw buffers. The renderer's maxBytes does not cover it.

A plain Map never evicts entries. If each render uses a different URL, pass a bounded cache such as an LRU, or leave fetchCache out.

A fetchCache hit checks only the requested URL, because the cache does not record where a redirect led. If two routes check URLs differently, such as one with an allowUrl for user input and one that loads only your own files, give each route its own fetchCache. Otherwise one route can serve bytes the other fetched through a redirect its own check would have refused.

Emoji images from the emoji option are cached without a fetchCache, up to 8 MiB for each fetch function.

Release fonts

A renderer keeps every font a render loads. The fonts budget caps the decoded bytes that renders' fonts lists may keep. These bytes count apart from maxBytes, so the pool and the fonts each keep their own bytes:

Cap the fonts a renderer keeps
import {  } from "takumi-js/node";

const  = new ({ : { : { : 64 * 1024 * 1024 } } }); 

Past the cap, the least recently used families are released, and a later render that lists one loads it again. Fonts from registerFont always stay. The font loaded last also stays, even if it alone exceeds the cap, so with a cap of 0 the renderer keeps only its registered fonts and the last font a render loaded. Set the cap above what one render needs. To release fonts yourself, see Releasing fonts.

Google Fonts CSS

googleFonts keeps the CSS responses it fetches, up to 1 MiB and 64 responses for each fetch function, and concurrent requests for the same URL share one fetch. Its cache option replaces that cache with your own Map. A request with signal or allowUrl skips the cache, so cancellation and URL checks stay with the caller. maxBytes applies to cache hits too.

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:

Share a cache across renders
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