Emoji rendering in screenshots

Emoji in a screenshot are drawn by a font on the rendering machine, not on the visitor's phone. This guide explains which emoji font the renderer has, what that means for flags, skin tones and new emoji, and how to use a different emoji font when you control the HTML. More guides are on the guides hub.

Last verified on 2026-10-15 against Cloudflare's font list and a local Puppeteer 24.43.1 (Chrome 148) test of the HTML on this page.

Which emoji font the renderer uses

RenderScreenshot renders in Cloudflare Browser Run. Cloudflare's supported fonts list (checked 2026-10-15) includes Noto Color Emoji, Google's open-source color emoji font. When a page shows an emoji and its own fonts have no glyph for it, Chromium falls back to Noto Color Emoji.

What follows from that:

  • The style is Google's. A page viewed on an iPhone shows Apple's emoji. The same page in a screenshot shows Noto's. Neither is wrong; they are different fonts.
  • Flags render. Noto Color Emoji draws country flags from regional indicator pairs. Windows browsers show letters such as "JP" for the same characters, so a screenshot can show flags that some of your visitors do not see.
  • Skin tones and joined sequences render as one glyph when the font knows the sequence, for example a thumbs-up with a skin-tone modifier, or "woman technologist" (woman + zero-width joiner + laptop).
  • The newest emoji can lag. The font version follows Cloudflare's environment. An emoji added to Unicode after that version shows as a box or as its parts side by side.

The request: emoji in your own HTML

When you send HTML, you choose the fonts. This request draws the same emoji twice: once with the default fallback (Noto Color Emoji) and once with Google's monochrome Noto Emoji web font, loaded from Google Fonts.

curl

curl -sS --fail -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
      "html": "<!doctype html><html><head><meta charset=\"utf-8\"><link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Noto+Emoji&display=block\"><style>body{margin:0;padding:48px;background:#fff;color:#111;font:56px/1.4 system-ui,sans-serif}.mono{font-family:Noto Emoji,sans-serif}</style></head><body><p>Default: 🚀 👍🏽 🇯🇵 👩‍💻</p><p class=\"mono\">Noto Emoji: 🚀 👍🏽 🇯🇵 👩‍💻</p></body></html>",
      "viewport": {
        "width": 1200,
        "height": 400
      },
      "wait": {
        "until": "networkidle"
      }
    }' \
  -o emoji.png

Node.js

import { writeFile } from 'node:fs/promises';

const body = {
  "html": "<!doctype html><html><head><meta charset=\"utf-8\"><link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Noto+Emoji&display=block\"><style>body{margin:0;padding:48px;background:#fff;color:#111;font:56px/1.4 system-ui,sans-serif}.mono{font-family:Noto Emoji,sans-serif}</style></head><body><p>Default: 🚀 👍🏽 🇯🇵 👩‍💻</p><p class=\"mono\">Noto Emoji: 🚀 👍🏽 🇯🇵 👩‍💻</p></body></html>",
  "viewport": {
    "width": 1200,
    "height": 400
  },
  "wait": {
    "until": "networkidle"
  }
};

const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RS_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(body),
});

if (!res.ok) {
  console.error(res.status, await res.text());
  process.exit(1);
}

await writeFile('emoji.png', Buffer.from(await res.arrayBuffer()));

Python

import json
import os
import sys
import urllib.error
import urllib.request

body = {
    'html': '<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Emoji&display=block"><style>body{margin:0;padding:48px;background:#fff;color:#111;font:56px/1.4 system-ui,sans-serif}.mono{font-family:Noto Emoji,sans-serif}</style></head><body><p>Default: 🚀 👍🏽 🇯🇵 👩‍💻</p><p class="mono">Noto Emoji: 🚀 👍🏽 🇯🇵 👩‍💻</p></body></html>',
    'viewport': {
        'width': 1200,
        'height': 400,
    },
    'wait': {
        'until': 'networkidle',
    },
}

req = urllib.request.Request(
    'https://api.renderscreenshot.com/v1/screenshot',
    data=json.dumps(body).encode(),
    headers={
        'Authorization': f"Bearer {os.environ['RS_API_KEY']}",
        'Content-Type': 'application/json',
    },
    method='POST',
)

try:
    with urllib.request.urlopen(req, timeout=90) as res:
        with open('emoji.png', 'wb') as f:
            f.write(res.read())
except urllib.error.HTTPError as err:
    sys.exit(f'{err.code} {err.read().decode()}')

Ruby

require 'json'
require 'net/http'

body = {
  html: '<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Emoji&display=block"><style>body{margin:0;padding:48px;background:#fff;color:#111;font:56px/1.4 system-ui,sans-serif}.mono{font-family:Noto Emoji,sans-serif}</style></head><body><p>Default: 🚀 👍🏽 🇯🇵 👩‍💻</p><p class="mono">Noto Emoji: 🚀 👍🏽 🇯🇵 👩‍💻</p></body></html>',
  viewport: {
    width: 1200,
    height: 400
  },
  wait: {
    until: 'networkidle'
  }
}

uri = URI('https://api.renderscreenshot.com/v1/screenshot')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{ENV.fetch('RS_API_KEY')}"
request['Content-Type'] = 'application/json'
request.body = body.to_json

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', read_timeout: 90) do |http|
  http.request(request)
end
abort("#{response.code} #{response.body}") unless response.is_a?(Net::HTTPSuccess)

File.binwrite('emoji.png', response.body)

PHP

<?php

$body = [
    'html' => '<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Emoji&display=block"><style>body{margin:0;padding:48px;background:#fff;color:#111;font:56px/1.4 system-ui,sans-serif}.mono{font-family:Noto Emoji,sans-serif}</style></head><body><p>Default: 🚀 👍🏽 🇯🇵 👩‍💻</p><p class="mono">Noto Emoji: 🚀 👍🏽 🇯🇵 👩‍💻</p></body></html>',
    'viewport' => [
        'width' => 1200,
        'height' => 400,
    ],
    'wait' => [
        'until' => 'networkidle',
    ],
];

$ch = curl_init('https://api.renderscreenshot.com/v1/screenshot');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('RS_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 90,
]);

$image = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($image === false || $status !== 200) {
    fwrite(STDERR, $status . ' ' . ($image === false ? curl_error($ch) : $image) . "\n");
    exit(1);
}

file_put_contents('emoji.png', $image);

Go

package main

import (
    "bytes"
    "io"
    "log"
    "net/http"
    "os"
)

const body = `{
  "html": "<!doctype html><html><head><meta charset=\"utf-8\"><link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Noto+Emoji&display=block\"><style>body{margin:0;padding:48px;background:#fff;color:#111;font:56px/1.4 system-ui,sans-serif}.mono{font-family:Noto Emoji,sans-serif}</style></head><body><p>Default: 🚀 👍🏽 🇯🇵 👩‍💻</p><p class=\"mono\">Noto Emoji: 🚀 👍🏽 🇯🇵 👩‍💻</p></body></html>",
  "viewport": {
    "width": 1200,
    "height": 400
  },
  "wait": {
    "until": "networkidle"
  }
}`

func main() {
    req, err := http.NewRequest(http.MethodPost, "https://api.renderscreenshot.com/v1/screenshot", bytes.NewBufferString(body))
    if err != nil {
        log.Fatal(err)
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("RS_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    res, err := http.DefaultClient.Do(req)
    if err != nil {
        log.Fatal(err)
    }
    defer res.Body.Close()

    data, err := io.ReadAll(res.Body)
    if err != nil {
        log.Fatal(err)
    }
    if res.StatusCode != http.StatusOK {
        log.Fatalf("%d %s", res.StatusCode, data)
    }

    if err := os.WriteFile("emoji.png", data, 0o644); err != nil {
        log.Fatal(err)
    }
}

Java

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;

public class Capture {
    public static void main(String[] args) throws Exception {
        String body = """
            {
              "html": "<!doctype html><html><head><meta charset=\\"utf-8\\"><link rel=\\"stylesheet\\" href=\\"https://fonts.googleapis.com/css2?family=Noto+Emoji&display=block\\"><style>body{margin:0;padding:48px;background:#fff;color:#111;font:56px/1.4 system-ui,sans-serif}.mono{font-family:Noto Emoji,sans-serif}</style></head><body><p>Default: 🚀 👍🏽 🇯🇵 👩‍💻</p><p class=\\"mono\\">Noto Emoji: 🚀 👍🏽 🇯🇵 👩‍💻</p></body></html>",
              "viewport": {
                "width": 1200,
                "height": 400
              },
              "wait": {
                "until": "networkidle"
              }
            }
            """;

        HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.renderscreenshot.com/v1/screenshot"))
            .header("Authorization", "Bearer " + System.getenv("RS_API_KEY"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(body))
            .build();

        HttpResponse<byte[]> response = HttpClient.newHttpClient()
            .send(request, HttpResponse.BodyHandlers.ofByteArray());
        if (response.statusCode() != 200) {
            System.err.println(response.statusCode() + " " + new String(response.body()));
            System.exit(1);
        }

        Files.write(Path.of("emoji.png"), response.body());
    }
}

Why the HTML is built this way:

  • display=block in the font URL tells the browser to wait for the font instead of drawing a fallback first.
  • wait.until: "networkidle" keeps the renderer waiting until the font file has downloaded. In our local test of this HTML, document.fonts.check('56px "Noto Emoji"') was true when the page finished loading.
  • background:#fff is set on purpose. Without a background, the page canvas follows the browser's color scheme, and a dark canvas made black text unreadable in our local test.
  • The font family name is unquoted (Noto Emoji), which CSS allows, so the JSON stays free of single quotes and the curl example works as shown.

The monochrome font draws one-color outlines. It ignores skin-tone colors and draws flags as outlines with letters, which is expected for a one-color font.

Emoji from user input

Social cards often put a user's title, emoji included, into HTML. Escape the text before you insert it, or a title such as <script> becomes part of the page:

// Build a social card from a user-supplied title that may contain emoji.
// The title is escaped because it is inserted into HTML.
import { writeFile } from 'node:fs/promises';

const escapeHtml = (s) =>
  s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);

const title = process.argv[2] ?? 'Launch day 🚀 <and> "quotes" & more';

const html = `<!doctype html><html><head><meta charset="utf-8"><style>
body{margin:0;width:1200px;height:630px;display:flex;align-items:center;padding:0 80px;box-sizing:border-box;
background:#0f172a;color:#f8fafc;font:700 64px/1.2 system-ui,sans-serif}
</style></head><body><h1>${escapeHtml(title)}</h1></body></html>`;

const res = await fetch('https://api.renderscreenshot.com/v1/screenshot', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RS_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ html, preset: 'og_card' }),
});

if (!res.ok) {
  console.error(res.status, await res.text());
  process.exit(1);
}

await writeFile('card.png', Buffer.from(await res.arrayBuffer()));

The escaping changes only & < > " '. Emoji pass through as they are, and the og_card preset sets the 1200x630 viewport.

Another emoji font on a live URL

For a URL capture you do not control, you can inject a font with page.styles. Know the timing first: the renderer injects styles after the wait step, just before the capture. The stylesheet is added, but the font file it points to may still be downloading when the capture runs. The emoji then show in the fallback font.

If the emoji style matters, use one of these instead:

  • Add the font to the page's own HTML, so it loads during the normal wait.
  • Fetch the page's content yourself and send it as html, with the font in <head>, as in the request above.

Font stack order

Put the emoji font after your text font and before the generic family:

body { font-family: Inter, Noto Emoji, sans-serif; }

Emoji fonts also contain glyphs for some plain characters, such as digits, # and *, because these start keycap sequences. With the emoji font first, those characters can come from it.

Text or emoji presentation

Some characters, such as the heart U+2764, default to a plain text glyph. Add the variation selector U+FE0F after the character to ask for the color emoji. In HTML, write &#x2764;&#xFE0F;.

Troubleshooting

Symptom Cause Fix
Emoji look different from an iPhone The renderer uses Noto Color Emoji Expected. Load another emoji font through HTML input if you have the right to use it.
Empty box instead of an emoji The emoji is newer than the installed font Use an older emoji, or load a newer emoji web font in your HTML.
Two emoji side by side instead of one The font does not know that joined sequence Same fix as above.
Black-and-white heart Text presentation Add U+FE0F after the character.
Injected emoji font not used on a URL capture The font file loaded after the capture Use HTML input, or add the font to the page.
Unreadable text on a dark background No background set in your HTML Set background and color on body.

Limits

  • Use absolute https:// URLs for fonts and images in HTML input, as the example does.
  • The page must load within wait.timeout: 30 seconds by default, 60 at most.
  • Read the license of any emoji font you embed. Noto Emoji and Noto Color Emoji are under the SIL Open Font License. Apple Color Emoji is licensed for Apple platforms only.

Related in this topic

Was this page helpful?