Takumi

Performance

Reuse renderers, size the image and glyph caches, and keep memory and render time down on a server.

Reuse the renderer, size the caches, and measure before you change a budget. Each section below covers one of these steps and the limits that go with it.

Reuse the renderer

Reuse the renderer
import {  } from "takumi-js";

const  = await (< ="p-8 text-4xl">Hello Takumi</>, {
  : 1200,
  : 630,
});

render and ImageResponse reuse one managed renderer, so they need no cache setup. If you create a Renderer yourself, create it outside the request handler so its registered fonts and decoded resources survive between renders.

Share one renderer between requests

One Renderer takes overlapping calls without a lock. Concurrent requests can call render, prepare, renderSvg, renderAsDataUrl, measure, renderAnimation, and registerFont on it at once, and each call draws what it would draw alone with the same fonts. Two calls that list the same font load it once. On WebAssembly, JavaScript runs one call into the module at a time, and other calls run only while one waits for a font or image to download. The native renderer draws calls in parallel on worker threads, and each draw keeps the fonts it started with.

A few calls change what overlapping calls see:

  • unregisterFont and clearFonts remove fonts from calls that have not started drawing. A call that lists a removed font in fonts loads it again. A call that relied on registerFont draws with the fallback font instead.
  • With fontCacheMaxBytes, a call can release a font another call loaded. The other call loads it again before it draws. A call that lists more fonts than the budget holds can still draw without some of them.
  • free() ends the renderer, and calls still in progress on WebAssembly reject with null pointer passed to rust. Free a renderer only after its calls settle.

An aborted signal rejects its call with an AbortError. The call stops downloading and does not start its next step, so a call waiting for fonts or images ends early. On the native renderer, so does a call still waiting for a worker thread. A draw that has already started runs to the end, and the other calls on the renderer are unaffected.

A panic ends the process

The native addon is compiled with panic = "abort". A panic in Takumi, which is always a bug, ends the whole Node.js or Bun process with every request in it. Worker threads share the process, so rendering in a worker_threads worker does not contain it. To keep a crash from taking your server down, render in a child process. On WebAssembly, a panic throws RuntimeError: unreachable from that call instead, and the process keeps running. Please report the input that caused it.

Know what is cached

The table lists each kind of cached work, how long it lives, and how to configure it.

WorkLifetimeConfiguration
Decoded images, SVG rasters, parsed stylesheetsRenderercacheMaxBytes, 64 MiB by default
Glyph outlines and rasterized masksLoaded backend modulesetGlyphCacheMaxBytes, 8 MiB by default
Parsed Tailwind classesLoaded backend moduleAutomatic, bounded internally
Resolved Tailwind declarationsRenderAutomatic, separated by viewport width, font size, and pixel ratio
Google Fonts CSSFetch implementationAutomatic, bounded internally
Downloaded image bytesCaller-provided cacheOptional images.fetchCache

Cache budgets count retained entries, not total process memory. Fonts, active renders, output buffers, and allocator overhead use memory too. See image caching and Google Fonts for resource-specific behavior.

Measure cold and repeated renders

Measure the first render separately from repeated renders, because font downloads and decoding slow the first one. Layout, painting, and encoding still run on a warm renderer. Use representative text, images, output formats, and concurrent requests. Compare latency and memory before you change a budget. A larger cache helps only when later renders reuse what it keeps.

Resource cache

Resource cache
import {  } from "takumi-js/node";

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

Raise the budget when reused images or stylesheets get evicted. For an image used once, cache: "none" keeps it from pushing out reusable entries. This budget does not cover images.fetchCache, so bound that cache yourself.

Glyph cache

Glyph cache
import {  } from "takumi-js";

(64 * 1024 * 1024); 

Call setGlyphCacheMaxBytes before the first render. A later call does not resize a cache already in use.

Every renderer in the same backend module shares this budget. Large glyph sets, such as CJK text, can need more space, so benchmark your content first.

Keep memory flat on Linux servers

Each render allocates a few megabytes for its canvas and encoder buffers. On Linux, glibc keeps freed memory in its arenas, so a long-running process holds more memory than the renderer retains.

Fixed mmap threshold
GLIBC_TUNABLES=glibc.malloc.mmap_threshold=262144 node server.js

glibc raises its mmap threshold after a large block is freed, so later canvases land in the arenas. A fixed threshold keeps buffers over 256 KiB in their own mappings, and glibc unmaps each one when it is freed.

SettingRSS over 1,275 rendersRender time
Default189–235 MBBaseline
GLIBC_TUNABLES=glibc.malloc.mmap_threshold=262144114–130 MB5–7% slower
MALLOC_ARENA_MAX=2165–211 MB7% slower with 4 renders at once

These figures come from one test setup, described in PR #1862. It used the node:24-bookworm-slim image on Linux arm64 and rendered 65 OG templates four at a time. Both settings apply to the whole process, so measure your own server first. Alpine and other musl images use a different allocator.

Keep network requests out of repeated work

Bundle fonts for a predictable startup without a font service. WOFF2 files are smaller but need decompression, while TTF files skip that step. A reused renderer decodes a registered file once (see Fonts in CI and offline renders). For repeated remote images, use a bounded fetch cache, since the renderer's decode cache does not replace a download cache.

Reduce painting work

Each filtered node needs an offscreen layer. Put filters on one node when they should apply to the same composed content. Moving filters from children to a parent can change the output, so compare the result.

Render large canvases

A canvas holds at most 64 megapixels, such as 8192 × 8192 or 4096 × 16384.

A 4K render
import {  } from "takumi-js";

const  = await (< ="p-8 text-4xl">Hello Takumi</>, {
  : 3840,
  : 2160,
});

A larger viewport fails with Invalid viewport dimensions: a canvas holds 1 to 64 megapixels. Each pixel takes 4 bytes, so a full 64-megapixel render holds 256 MiB before encoding.

Painting cost follows the area a node covers, not the canvas size. A backdrop-filter blurs only the pixels under its node. A clip mask costs only the clipped box.

Last updated on