· Troubleshooting · By Simon Chiu

Puppeteer Emoji on Linux: Fonts, Docker and Fixes

Fix missing or black-and-white emoji in Puppeteer on Linux: tested Dockerfile lines for Noto Color Emoji, the DejaVu glyph trap, and Twemoji via @font-face.

If emoji show up as empty boxes, or some come out black and white, in Puppeteer screenshots on a Linux server, the fix is fonts. Debian's Chromium package does not bring a color emoji font, and the fonts it does bring get in the way. These lines fixed every emoji in our test:

RUN apt-get update \
 && apt-get install -y --no-install-recommends chromium fonts-noto-color-emoji fonts-liberation \
 && rm -rf /var/lib/apt/lists/*
COPY 60-sans-prefer.conf /etc/fonts/conf.d/60-sans-prefer.conf
RUN fc-cache -f

The second half of that, the fontconfig file, is the part most guides leave out. This post shows each step with what it changed, then the options for a different emoji style: Twemoji, and why not Apple's. It is part of our Puppeteer screenshot guide.

Tested with: Docker image node:22-bookworm-slim (Debian 12), Debian's Chromium 155.0.8059.79, puppeteer-core 25.14.0, fonts-noto-color-emoji 2.042-0+deb12u1, fonts-liberation 1:1.07.4-11. Run on an Apple M1 Pro under Docker (linux/arm64). Tested on 2026-10-18.

The test

Every render below draws the same seven emoji at 56 px: rocket 🚀, grinning face 😀, thumbs up with a medium skin tone 👍🏽, the flag of Japan 🇯🇵, woman technologist 👩‍💻 (a zero-width-joiner sequence), red heart ❤️ (heart plus a variation selector) and melting face 🫠 (added in Emoji 14.0). Text uses the generic sans-serif family, which is what most pages fall back to.

Setup 🚀 😀 👍🏽 🇯🇵 👩‍💻 ❤️ 🫠
1. Chromium only box mono 2 boxes 2 boxes 2 boxes mono box
2. + fonts-noto-color-emoji color mono color color color color color
3. + unicode-range @font-face, emoji blocks only color color color color split color color
4. + unicode-range incl. ZWJ and selectors color color color color color color color
5. + fontconfig charset edit on DejaVu color mono color color color color color
6. + fonts-liberation as default sans-serif color color color color color color color
7. Twemoji web font color color color color color color color

"Mono" means a black-and-white outline glyph. "Split" means the woman and the laptop drew side by side instead of as one glyph. The sections below explain each row.

1. What Chromium brings: almost nothing

We installed Chromium with --no-install-recommends on node:22-bookworm-slim. fc-list then showed six font files, all from the DejaVu family. DejaVu Sans covers some emoji code points in black and white, like the grinning face and the heart. Everything else became a box ("tofu"), and the multi-code-point emoji became two boxes. See font fallback and tofu for how Chromium picks a font for each character.

2. Install Noto Color Emoji, and meet the DejaVu trap

RUN apt-get update \
 && apt-get install -y --no-install-recommends fonts-noto-color-emoji \
 && rm -rf /var/lib/apt/lists/*

The package is about 10.5 MB installed. After it, six of seven emoji rendered in color, including the flag, the skin tone, the ZWJ sequence and the Emoji 14.0 melting face.

The grinning face did not. It stayed a black-and-white outline, because DejaVu Sans has a glyph for it. Chromium walks the font list for each character and takes the first font with a glyph. sans-serif resolved to DejaVu Sans, DejaVu had 😀, so Noto never got asked. This is why "I installed the emoji font and some emoji are still wrong" is such a common report: which emoji break depends on which code points your default text font happens to cover.

There are two fixes. One works in the page, the other on the server.

3 and 4. Fix it in the page: unicode-range

If you control the HTML (an OG card template, a report), define a font family that maps only emoji code points to Noto Color Emoji, and put it first:

@font-face {
  font-family: "Emoji";
  src: local("Noto Color Emoji");
  unicode-range: U+1F000-1FAFF, U+2600-27BF, U+200D, U+FE0F, U+20E3, U+E0020-E007F;
}
.e { font-family: "Emoji", sans-serif; }

Our first version had only the two emoji blocks in the range (row 3). That fixed the grinning face and broke the woman technologist: the zero-width joiner (U+200D) between "woman" and "laptop" was outside the range, so it went to DejaVu, and Chromium could not shape the sequence as one glyph. Adding U+200D, the variation selector U+FE0F, the keycap mark U+20E3 and the tag characters used by subdivision flags (U+E0020 to U+E007F) fixed it (row 4).

5. A fontconfig trick that did not work

A widely shared fix edits DejaVu's character map at scan time, removing the emoji ranges so fontconfig stops offering DejaVu for them:

<match target="scan">
  <test name="family"><string>DejaVu Sans</string></test>
  <edit name="charset" mode="assign">
    <minus>
      <name>charset</name>
      <charset><range><int>0x1F300</int><int>0x1FAFF</int></range></charset>
    </minus>
  </edit>
</match>

After fc-cache -f, fc-match "sans-serif:charset=1f600" did return Noto Color Emoji. Chromium still drew the grinning face from DejaVu (row 5). Our reading: Chromium asks fontconfig which font sans-serif means, gets DejaVu, and then checks the glyph table inside the font file when it shapes text. The file still has the glyph, and a fontconfig charset edit does not change the file. We do not recommend this fix for Chromium.

6. Fix it on the server: a default sans-serif without emoji

For pages you do not control, change what sans-serif resolves to. Liberation Sans is metric-compatible with Arial and has no emoji glyphs, so every emoji falls through to Noto Color Emoji. Save this as 60-sans-prefer.conf:

<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
  <!-- Resolve generic sans-serif to Liberation Sans, which has no emoji
       glyphs, so Chromium falls back to Noto Color Emoji for every emoji. -->
  <alias binding="strong">
    <family>sans-serif</family>
    <prefer><family>Liberation Sans</family></prefer>
  </alias>
</fontconfig>

The complete Dockerfile we tested:

FROM node:22-bookworm-slim

RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      chromium \
      fonts-noto-color-emoji \
      fonts-liberation \
 && rm -rf /var/lib/apt/lists/*

COPY 60-sans-prefer.conf /etc/fonts/conf.d/60-sans-prefer.conf
RUN fc-cache -f

ENV PUPPETEER_SKIP_DOWNLOAD=true
WORKDIR /app
RUN npm init -y >/dev/null && npm i [email protected]

With it, fc-match sans-serif and fc-match system-ui both returned Liberation Sans, and all seven emoji rendered in color (row 6). The two font packages and the config added about 20 MB to the image, which was 1.40 GB before and 1.42 GB after.

Two side effects to know. Text that asks for sans-serif or system-ui now renders in Liberation Sans instead of DejaVu Sans, so line widths change. And a page that names "DejaVu Sans" directly would still get DejaVu's monochrome emoji; we did not test that case. For more on keeping text fonts stable between machines, see consistent fonts in headless rendering.

We used Debian's chromium package because Puppeteer's downloaded Chrome for Testing has no Linux arm64 build. On x86-64 you can use Puppeteer's own Chrome instead; the font packages and the fontconfig file are the same.

The render script

This is the script we ran inside the container for each row. It writes /out/<label>.png and prints the state of any @font-face fonts, so you can see whether a web font actually loaded.

import puppeteer from 'puppeteer-core';

const label = process.argv[2] ?? 'render';
const fontCss = process.argv[3] === 'twemoji'
  ? `@font-face{font-family:"Twemoji";src:url("https://cdn.jsdelivr.net/npm/[email protected]/twemoji.woff2") format("woff2")}
     .e{font-family:"Twemoji",sans-serif}`
  : '.e{font-family:sans-serif}';

const html = `<!doctype html><html><head><meta charset="utf-8"><style>
  ${fontCss}
  body{margin:0;padding:32px;background:#fff;color:#111;font:20px sans-serif}
  .e{font-size:56px;line-height:1.3}
</style></head><body>
  <div>${label}</div>
  <div class="e">🚀 😀 👍🏽 🇯🇵 👩‍💻 ❤️ 🫠</div>
</body></html>`;

const browser = await puppeteer.launch({
  executablePath: '/usr/bin/chromium',
  args: ['--no-sandbox'], // container-only; see the security note in the article
});
try {
  const page = await browser.newPage();
  await page.setViewport({ width: 760, height: 200 });
  await page.setContent(html, { waitUntil: 'load' });
  await page.evaluate(() => document.fonts.ready);
  const t = await page.evaluate(() => [...document.fonts].map((f) => `${f.family}:${f.status}`).join(', '));
  await page.screenshot({ path: `/out/${label}.png` });
  console.log(label, t || '(no @font-face)');
} finally {
  await browser.close();
}

Run it with docker run --rm -v "$PWD/render.mjs:/app/render.mjs" -v "$PWD/out:/out" <image> node render.mjs 6-liberation.

Security note on --no-sandbox: we ran it as root in a throwaway container. In production, run Chromium as a non-root user so you can keep the sandbox, or isolate each container. Do not disable the sandbox on a shared host.

Wait for fonts. await document.fonts.ready matters for web fonts such as Twemoji. Without it, the screenshot can catch the fallback emoji before the web font arrives.

7. A different style: Twemoji

Noto Color Emoji draws Google's style. If you want Twitter's (now X's) style, load a Twemoji font in COLR format as a web font and put it first for emoji elements. We used the twemoji-colr-font 15.0.3 package from jsDelivr; its twemoji.woff2 is 476 KB:

@font-face {
  font-family: "Twemoji";
  src: url("https://cdn.jsdelivr.net/npm/[email protected]/twemoji.woff2") format("woff2");
}
.e { font-family: "Twemoji", sans-serif; }

All seven emoji rendered in Twemoji's flat style (row 7), including the grinning face, because a web font listed first wins over DejaVu. The package's license file lists the SIL Open Font License 1.1 for the font file. The Twemoji artwork is published under CC BY 4.0, which asks for attribution; check both before you ship.

For production, copy the woff2 file next to your template instead of fetching it from a CDN on every render: one less network request, and no failure when the CDN is slow.

What about Apple emoji?

Apple Color Emoji ships with macOS and iOS, and its license allows use on Apple platforms. Copying the font file into a Linux container to make screenshots look like an iPhone is outside that license, so we do not show how. If your output must match what iPhone users see, render on macOS hardware you own. For everyone else, Noto Color Emoji or Twemoji are openly licensed and look familiar to most readers.

Troubleshooting

Symptom Cause Fix
Emoji are empty boxes No color emoji font installed apt-get install fonts-noto-color-emoji
Some emoji are black and white DejaVu Sans (or another text font) has a monochrome glyph for them Make sans-serif resolve to Liberation Sans, or use a unicode-range @font-face
ZWJ emoji draw as two pictures The joiner U+200D is outside your emoji font's unicode-range Add U+200D, U+FE0F, U+20E3 and U+E0020-E007F
Flags show as two letters or two boxes The emoji font in use has no flag glyphs Noto Color Emoji and Twemoji both draw flags
New emoji show as a box Your emoji font is older than the emoji Update the package or use a newer web font
Fixed locally, broken in the container macOS and Windows ship color emoji fonts; slim Linux images do not Test in the same image you deploy
fontconfig change had no effect Cache not rebuilt, or a charset edit (Chromium checks the font file) fc-cache -f after copying the file; prefer the alias fix
Twemoji not used Web font still loading when the screenshot ran await page.evaluate(() => document.fonts.ready)

The same emoji with one API call

RenderScreenshot renders in Cloudflare's browser environment, which includes Noto Color Emoji, so emoji work without a Dockerfile. When you send your own HTML, you choose the fonts, including the unicode-range and Twemoji options above. Our emoji rendering guide covers what the renderer has installed and shows a request that draws the same emoji with two fonts.

import { writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);

const image = await client.take(
  TakeOptions.html('<!doctype html><meta charset="utf-8"><p style="font:56px sans-serif">🚀 👍🏽 🇯🇵 👩‍💻</p>')
    .width(800)
    .height(200)
);
await writeFile('emoji.png', image);

Get a free API key to try it with your own templates.

Related in this topic