Node.js

How I Generated 5,300+ Multilingual Social Graphs in Seconds with Satori & Node.js

M
MatchMyType Team
/

How I Generated 5,300+ Multilingual Social Graphs in Seconds with Satori & Node.js

Summary: I migrated from a Puppeteer-based OG image generation pipeline (2.5s per image) to a Node.js script using Vercel Satori + Resvg (40ms per image). This reduced my build time by 98% and enabled me to generate 5,376 unique, localized assets for MatchMyType.org in under 4 minutes on a standard MacBook.

The Problem: The Combinatorial Explosion

I run a personality analysis site that matches the 16 MBTI types across 3 dimensions: Love, Work, and Friendship. We support 7 languages (English, Chinese, Japanese, Korean, Indonesian, Thai, Traditional Chinese).

Here is the math of my nightmare:

  • 16 Type A users
  • 16 Type B users
  • 3 Dimensions (Love, Work, Friendship)
  • 7 Languages

Total unique assets needed: 16 * 16 * 3 * 7 = 5,376 images.

Why not use dynamic generation?

I considered using Vercel's ImageResponse (Edge Functions) to generate these on the fly. However, social media crawlers (TwitterBot, FacebookBot, WeChat) are notoriously impatient. If your OG image takes >2 seconds to render (common with cold starts), they timeout and show a gray box. Plus, generating the same static image of "INTJ x ENFP" 10,000 times a day is a waste of compute and money. Static Generation (SSG) was the only viable path for zero-latency sharing.

Attempt 1: The Puppeteer Approach (Failed)

My first script spawned a headless Chrome instance to screenshot a local HTML page.

// The old slow way
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(`http://localhost:3000/og-preview?typeA=INTJ&typeB=ENFP`);
await page.screenshot({ path: 'output.png' });

The Result:

  • Time per image: ~2.5 seconds
  • Total time: ~3.7 hours
  • CPU: 100% usage, laptop sounding like a jet engine.
  • Flakiness: Random timeouts.

I optimized it with a connection pool, but getting it under 500ms was physically impossible due to the overhead of the Chromium binary.

Attempt 2: Satori + Resvg (Success)

Satori is a library (written in Rust/WASM) that converts HTML/CSS directly to SVG. It doesn't use a browser. It simulates the layout engine. Resvg is a high-performance SVG-to-PNG renderer.

The Stack

  • Node.js: Runtime
  • Satori: JSX -> SVG
  • Resvg: SVG -> PNG
  • React: Component definition (Yes, you can use React components in a Node script!)

Step 1: Defining the Component

I re-used my React components but stripped out client-side interactive logic. One specific challenge: Radar Charts. Client-side libraries like recharts rely on DOM APIs (measureText, window) which don't exist in Node.js. I had to write my own "Pure SVG" radar chart generator.

// Pure math, no DOM APIs needed
function calculatePolygon(values, radius, center) {
  const angleStep = (Math.PI * 2) / values.length;
  return values.map((val, i) => {
    const angle = i * angleStep - Math.PI / 2;
    const r = (val / 100) * radius;
    const x = center + r * Math.cos(angle);
    const y = center + r * Math.sin(angle);
    return `${x},${y}`;
  }).join(' ');
}

Step 2: Handling CJK Fonts

Satori requires raw font data buffers. It cannot load from the OS. Since I support Chinese, Japanese, Thai, and Korean, I had to map locales to specific Google Noto Sans font files.

const FONT_FILES = {
    'zh': 'noto-sans-sc-700.woff',
    'ja': 'noto-sans-jp-700.woff',
    'ko': 'noto-sans-kr-700.woff',
    'th': 'NotoSansThai-Bold.ttf', // Thai requires special handling
    'en': 'noto-sans-700.woff',
}

// In the loop
const fontData = fs.readFileSync(path.join(FONTS_DIR, FONT_FILES[locale]));

Step 3: Concurrency Control

Throwing 5,000 tasks at Promise.all is a Rookie Mistake™. It will OOM (Out of Memory) your Node process immediately. I wrote a simple "Worker Pool" pattern to keep exactly 80 concurrent threads running.

const CONCURRENCY = 80;

async function main() {
    const queue = [...allTasks];
    const workers = [];

    for (let i = 0; i < CONCURRENCY; i++) {
        workers.push((async () => {
            while (queue.length > 0) {
                const task = queue.shift();
                await generateImage(task); // <--- The heavy lifter
            }
        })());
    }

    await Promise.all(workers);
}

The Benchmark

Here is the final comparison between the two approaches on an Apple M1 Max:

| Metric | Puppeteer (Headless Chrome) | Satori + Resvg | Difference | | :--- | :--- | :--- | :--- | | Startup Time | ~1500ms | ~50ms | 30x faster | | Render Time (1 image) | ~800ms | ~40ms | 20x faster | | Memory Usage | 4GB+ (Chromium) | ~200MB | 95% less | | Total Build Time (5k images) | ~3.7 Hours | ~3.5 Minutes | 63x faster | | Output Quality | Pixel-perfect | Pixel-perfect | Same |

Takeaways for Engineers

  1. Avoid Headless Browsers for SSG: They are too heavy. If you just need a picture of HTML, use Satori.
  2. Concurrency Matters: Node.js is single-threaded, but Satori's WASM execution and Resvg's Rust binding run outside the main loop, allowing heavy parallelism.
  3. Typography is Hard: If you are doing multilingual generation, be prepared to manage font files manually. Satori does not have generic font fallbacks.

See it in Action

You can verify the result yourself. Go to MatchMyType.org, pick any two types (e.g., INTJ + ENFP), and share the link on Twitter/X or Discord. The image you see is one of the 5,376 assets generated by this script.


Build better relationships with data

MatchMyType uses the 16 personality types to help you understand your compatibility in Love, Work, and Friendship.

Try MatchMyType Free
How I Generated 5,300+ Multilingual Social Graphs in Seconds with Satori & Node.js