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-emoji2.042-0+deb12u1,fonts-liberation1: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 guides
- Taking screenshots with Puppeteer, the hub for this series
- Consistent fonts in headless rendering, for text fonts between machines
- Puppeteer in Docker, for the rest of a production image
- Emoji rendering in RenderScreenshot
Related in this topic
- Puppeteer Screenshots: Tested Recipes for Puppeteer 25 Every Puppeteer screenshot recipe re-run on Puppeteer 25.14 and Chrome 155: viewport, full page, element, clip, waits, formats, auth, blocking and fonts.
- Puppeteer on AWS Lambda with @sparticuz/chromium Run Puppeteer on AWS Lambda with @sparticuz/chromium: version matching, a container-image handler tested in the Lambda emulator, and warm reuse.
- Puppeteer in Docker: A Tested Dockerfile with Fonts A non-root Puppeteer Dockerfile with Chromium, CJK and emoji fonts and dumb-init, plus the sandbox error it hits, measured image sizes and launch times.
- Puppeteer Full Page Screenshot: Limits, Lazy Images, Fixes Puppeteer full page screenshot guide: the 16,384 px limit we measured, captureBeyondViewport, lazy-load scrolling, sticky headers and a tested script.
- Hide Cookie Banners and Chat Widgets in Puppeteer Remove cookie banners, consent iframes, shadow-DOM dialogs and chat widgets from Puppeteer screenshots: a tested module and what each method misses.
- Puppeteer HTML to PDF: page.pdf() Options Tested Generate PDFs with Puppeteer page.pdf(): paper size, margins, header and footer templates, page breaks and print CSS, with measured sizes and timings.