ScreenshotNeo

BlogHTML to image & PDF

How to Fix Missing Times New Roman in Puppeteer PDFs on Heroku

Fix missing Times New Roman in Puppeteer PDFs on Heroku by checking font resolution, installing fonts legally, and separating browser setup from font setup.

By the ScreenshotNeo team1 October 20268 min read

Direct answer: treat missing Times New Roman as a font-resolution problem first. Check which fonts the deployed Heroku process can resolve. If the exact typeface is required, make legally obtained Times New Roman files available during deployment and refresh the host font discovery mechanism. If an exact match is not required, Chromium’s Fontconfig aliases list Liberation Serif and Tinos as Times New Roman alternatives. Those fonts can preserve document metrics, but they are substitutes rather than the original typeface.

Keep font installation separate from Puppeteer’s browser setup. Heroku may also need a Chrome buildpack or Puppeteer buildpack so Chromium can launch, but a working browser does not prove that Times New Roman is installed.

1. Confirm what “missing” means

Before changing Heroku configuration, inspect a generated PDF. The symptom usually falls into one of three groups:

  • Missing glyphs: some characters appear as boxes or are absent. The selected font may not cover the required script.
  • Serif substitution: text renders, but the shape clearly differs from Times New Roman.
  • Layout change: line breaks, pagination, table widths, or headers move because the replacement font has different metrics.

The title alone does not identify which case you have. Save a PDF produced on Heroku and compare it with a PDF produced on a machine where the intended font is known to be available.

2. Check font resolution on the dyno

Fontconfig matches a requested family against fonts visible to the operating system and applies configuration rules to the match. Run these checks inside the deployed environment, or temporarily from a one-off Heroku dyno:

heroku run bash --app YOUR_APP
fc-list | head -n 20
fc-match "Times New Roman"
fc-match "Liberation Serif"
fc-match "Tinos"
fc-cache -f -v

fc-match reports the font Fontconfig would select for a family. It does not prove that Chromium will produce identical glyph shapes or pagination. Record the output in your deployment logs while diagnosing the issue, then remove verbose diagnostics if they are no longer needed.

For an application-level check, execute the command from Node.js and log the result:

import { execFile } from 'node:child_process';

execFile('fc-match', ['Times New Roman'], (error, stdout, stderr) => {
  if (error) {
    console.error('Fontconfig lookup failed:', error);
    process.exitCode = 1;
    return;
  }
  console.log(stdout.trim());
  if (stderr) console.error(stderr.trim());
});

Do not describe the font as installed until this check has been performed on the same Heroku runtime that creates the PDF.

3. Choose exact Times New Roman or a compatible substitute

Exact typeface

If branding, legal forms, or an acceptance test requires the original Times New Roman design, obtain font files through a license that permits your deployment and redistribution model. Package the files with your application or provide them through an approved build step. Do not copy proprietary font files into a public repository without the rights to do so.

A typical project layout is:

project/
  fonts/
    times-new-roman.ttf
    times-new-roman-bold.ttf
    times-new-roman-italic.ttf
  src/
    render-pdf.js

Register the files in your HTML or stylesheet with @font-face. Use the correct weight and style declarations so Chromium does not synthesize a different face:

@font-face {
  font-family: "Times New Roman App";
  src: url("file:///app/fonts/times-new-roman.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: block;
}

@font-face {
  font-family: "Times New Roman App";
  src: url("file:///app/fonts/times-new-roman-bold.ttf") format("truetype");
  font-weight: 700;
  font-style: normal;
  font-display: block;
}

body {
  font-family: "Times New Roman App", serif;
}

Using a unique family name avoids accidentally selecting a system substitute with the same generic family name. If your HTML is served over HTTP, reference the font from the same origin or configure the response for cross-origin font use.

Metric-compatible substitute

When the original typeface is not required, Chromium’s Fontconfig alias configuration lists Liberation Serif and Tinos as alternatives for Times New Roman. The Liberation Fonts project describes its fonts as intended for document-layout compatibility with Times New Roman. Compatibility helps preserve line lengths and pagination, but it does not make the substitute identical.

body {
  font-family: "Liberation Serif", "Tinos", serif;
}

Test representative documents after switching. Metric compatibility does not guarantee identical kerning, hinting, glyph coverage, or page breaks.

4. Install a font during Heroku deployment

Heroku’s slug is rebuilt during deployment, so a font installed only in an interactive dyno will disappear on the next release. Put installation in a repeatable build step and rebuild Fontconfig’s cache afterward.

Buildpack or custom build step

Use a buildpack or another deployment mechanism that your stack supports to place licensed font files in a directory visible to the dyno, such as an application-owned fonts directory. Then run a cache refresh:

mkdir -p .fonts
cp fonts/*.ttf .fonts/
fc-cache -f -v .fonts

The exact package-install command depends on the buildpack and stack image. Verify the resulting files with fc-list and fc-match after deployment rather than assuming a buildpack installed the requested family.

Fontconfig configuration

If you control a Fontconfig configuration directory, you can add an alias for a substitute. This changes family resolution; it does not create the original font:

<match target="pattern">
  <test name="family" qual="any">
    <string>Times New Roman</string>
  </test>
  <edit name="family" mode="prepend" binding="strong">
    <string>Liberation Serif</string>
    <string>Tinos</string>
  </edit>
</match>

Place configuration where the dyno’s Fontconfig search path can see it, refresh the cache, and confirm the result with fc-match.

5. Keep Puppeteer browser setup separate

Puppeteer’s Heroku troubleshooting guidance says Heroku’s Linux environment may lack browser dependencies. A Puppeteer Heroku buildpack can provide those dependencies, and Puppeteer documents launching with --no-sandbox in this deployment context. These steps address browser execution; they do not install Times New Roman.

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
  headless: true,
  args: ['--no-sandbox', '--disable-setuid-sandbox']
});

try {
  const page = await browser.newPage();
  await page.goto('https://example.com', { waitUntil: 'networkidle0' });
  await page.pdf({
    path: 'output.pdf',
    format: 'A4',
    printBackground: true
  });
} finally {
  await browser.close();
}

Chrome for Testing buildpack

Heroku’s Chrome for Testing announcement describes adding heroku-community/chrome-for-testing as the first buildpack. It makes chrome and chromedriver available on the dyno PATH. This is useful when you want Puppeteer to use a system executable:

heroku buildpacks:add --index 1 https://github.com/heroku/heroku-buildpack-chrome-for-testing.git --app YOUR_APP

The announcement defaults to Stable and discourages pinning a specific version because browsers become outdated quickly. Check compatibility with your Puppeteer version before changing the browser source.

Browser cache problems

If the error says Chrome cannot be found or launched, inspect Puppeteer’s browser cache. Deployment hosts may not include the default cache in the project. The community Heroku buildpack documents a version-specific workaround for Puppeteer v19 and later because Chromium’s cache location changed. Check your installed Puppeteer version and current buildpack documentation before copying any heroku-postbuild script.

6. Use a deterministic PDF rendering script

Wait for fonts before generating the PDF. This prevents a race where the page is printed while the web font is still loading:

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({
  headless: true,
  args: ['--no-sandbox', '--disable-setuid-sandbox']
});

try {
  const page = await browser.newPage();
  await page.goto('https://example.com/report', {
    waitUntil: 'networkidle0',
    timeout: 60000
  });

  await page.evaluate(async () => {
    await document.fonts.ready;
  });

  const resolved = await page.evaluate(() => {
    const sample = document.querySelector('body');
    return sample ? getComputedStyle(sample).fontFamily : null;
  });
  console.log('CSS family:', resolved);

  await page.pdf({
    path: 'report.pdf',
    format: 'A4',
    printBackground: true,
    preferCSSPageSize: true
  });
} finally {
  await browser.close();
}

document.fonts.ready confirms that font loading has settled from the page’s perspective. It does not validate the typeface’s licensing, visual identity, or pagination, so retain a PDF comparison step.

7. Troubleshooting checklist

Symptom Likely cause Fix
fc-match "Times New Roman" returns another family The requested font is absent or aliased Install licensed files, or explicitly choose Liberation Serif/Tinos and test layout.
Text appears as boxes Font lacks required characters Choose a font covering the document’s scripts and verify the selected face.
PDF layout changes between local and Heroku Different font metrics, weights, or browser versions Compare fc-match output, load exact weights, wait for document.fonts.ready, and pin only versions your compatibility policy supports.
Font works locally but not in production Files were not included in the slug or are outside Fontconfig’s path Check deployed file paths, permissions, fc-list, and run fc-cache during build.
Browser executable not found Puppeteer cache or Heroku dependencies are missing Inspect the Puppeteer version and cache location; use the appropriate Heroku browser buildpack or executable path.
Browser exits with sandbox errors Heroku dyno restrictions Use the documented --no-sandbox and --disable-setuid-sandbox launch arguments.
PDF prints before fonts load Rendering starts before web fonts finish Wait for document.fonts.ready and use an appropriate navigation wait condition.
Font files are rejected or unavailable License or redistribution restrictions Obtain deployment rights or use a compatible substitute whose license permits your distribution.

8. Performance, reliability, and cost considerations

  • Build time: install and cache fonts during the build, not for every request. Repeated installation increases latency and creates inconsistent releases.
  • Memory: close each browser, or reuse a controlled browser process with isolated pages. Unclosed pages and browsers can exhaust dyno memory.
  • Determinism: keep the same font files, Fontconfig configuration, Chromium source, and Puppeteer version across releases when pagination matters.
  • Verification: include a representative PDF fixture containing normal, bold, italic, punctuation, and every language script you support.
  • Cost: font installation itself does not remove Heroku dyno, browser, or build costs. A substitute can reduce operational complexity, but validate its visual and licensing fit first.

9. Or skip the browser setup

If you only need a clean screenshot or PDF from a URL, ScreenshotNeo provides a hosted capture API and MCP server. See the ScreenshotNeo documentation for the complete option list and request formats.

One GET request returns a PNG, JPEG, WebP, or PDF:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers identify the page verdict and billing result. Its MCP server provides take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and start with 1,000 screenshots per month at no charge.

10. FAQ

Does the Puppeteer Heroku buildpack install Times New Roman?

No. Browser dependencies and font availability are separate deployment concerns. Verify the font with Fontconfig inside the dyno.

Is Liberation Serif identical to Times New Roman?

No. It is intended to be document-layout compatible, but glyph design and rendering can differ.

Can I fix the issue with CSS alone?

CSS can select a family or load a web font, but it cannot supply a missing system font unless the font file is available to the page.

Should I pin Chrome to one version?

Only when your compatibility policy requires it and you can maintain updates. Heroku’s Chrome for Testing guidance warns that pinned browsers become outdated quickly.

Why does my local PDF pass while Heroku fails?

Your local machine may have fonts, browser dependencies, or a cache that the dyno does not. Compare font resolution, browser executable paths, and loaded font files in both environments.