Html2Pdf is officially live in production:
- Live Web App & Interactive Demo: html2pdf.akcraft.app
- Official TypeScript / ESM SDK: @akcraft/html2pdf-sdk
- Live Web API: api.html2pdf.akcraft.app
If you have ever run headless Chromium, Puppeteer, or Playwright inside a Docker container in production, you might have run into a quiet, insidious issue: zombie processes piling up in your process table, container shutdowns that hang for 10 seconds before being brutally killed by SIGKILL, or random container crashes under high traffic.
In our project—html2pdf, a high-performance HTML-to-PDF microservice built with Fastify, Puppeteer, and Node 22—we recently ran into this exact challenge.
Here is the story of how zombie processes were sneaking into our containers, the hidden quirks of Linux's PID 1, and how adding tini in a two-line Dockerfile change fixed the problem for good.
1. The Context: Headless Chromium in Production
Our backend service (api/) accepts HTML payloads, renders pixel-perfect documents using headless Chromium, uploads the resulting PDFs to cloud storage, and returns presigned URLs.
Under the hood, every PDF generation delegates to Puppeteer:
// api/helpers/pdfHelper.ts
export async function saveHtmlToPdf(htmlString: string, jobId: string, options: PdfOptions = {}) {
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
// ...
],
});
const page = await browser.newPage();
await page.setContent(htmlString, { waitUntil: 'load' });
const pdfBuffer = await page.pdf({ /* format, margins, headers */ });
await browser.close();
// ... upload and return
}
This works smoothly on local machines. But inside a Linux container, headless Chromium is not just a single process. It is a multi-process architecture consisting of:
- The Browser parent process
- The Zygote process (which forks new renderer processes)
- Renderer processes (one per tab or isolated context)
- The GPU process
- Utility / Network / Crash-reporter subprocesses
When you run hundreds or thousands of PDF conversions per hour, dozens of these child processes are spawned, executed, and closed continuously.
And that is where container physics starts to work against you.
2. The Problem: The Zombie Process Apocalypse
When a Linux process finishes executing, it doesn't immediately vanish from the system. It enters a state called TASK_DEAD or ZOMBIE ([defunct] in ps output). The operating system keeps an entry in the process table holding its PID, exit code, and resource statistics so that its parent can read it using the wait() or waitpid() system calls.
Once the parent reads the exit status, the zombie is cleared ("reaped").
[Parent Process]
│
├─ forks ──► [Child Subprocess]
│ │
│ executes
│ │
│ terminates
│ ▼
│ [ZOMBIE / DEFUNCT]
│ │
├─ waitpid() ────────┘ (Reaped & Removed)
▼
What happens when the parent dies or crashes?
If a parent process terminates before its children, those children become orphans. In a standard Linux operating system, orphaned processes are immediately reparented to the ancestor process: PID 1 (traditionally init or systemd).
PID 1 has a fundamental operating system contract:
- Adopt all orphaned processes.
- Constantly call
waitpid(-1, ...)upon receivingSIGCHLDto reap zombies.
The Docker Catch: Node.js as PID 1
In a typical Docker container:
CMD ["node", "index.js"]
Because node is the first process launched in the container, Node.js runs as PID 1.
Container PID Namespace
└─ PID 1: node index.js
└─ PID 14: chromium (browser)
└─ PID 18: chromium (renderer)
└─ PID 22: chromium (crash-reporter)
Here lies the architectural friction:
-
Node.js is an application runtime, not an OS init system. Node does not listen for
SIGCHLDsignals from arbitrary orphaned processes, nor does it run a reaping loop. - When Chromium renderers crash, timeout, or get detached, they are reparented to PID 1 (Node).
- Node ignores them. The defunct process table entries stay in memory forever.
Over time, running ps aux inside the container reveals an ever-growing graveyard:
appuser 14 0.0 0.0 0 0 ? Z 14:20 0:00 [chromium] <defunct>
appuser 22 0.0 0.0 0 0 ? Z 14:21 0:00 [chromium] <defunct>
appuser 47 0.0 0.0 0 0 ? Z 14:23 0:00 [chromium] <defunct>
Eventually, the container exhausts the kernel's process ID limit (kernel.pid_max or container cgroup pids.max), throwing:
Error: spawn ENOMEM / EAGAIN: Resource temporarily unavailable
The Second Catch: Broken Signal Handling (SIGTERM)
Linux gives PID 1 special status regarding signals: the kernel does not register default signal handlers for PID 1.
If PID 1 has not explicitly registered a handler for SIGTERM or SIGINT, the kernel will silently discard the signal.
When deploying a new version to Render, Kubernetes, or Docker:
- Docker sends
SIGTERMto PID 1. - If Node or the shell doesn't cleanly handle and propagate it to all children, the container ignores it.
- Docker waits for a graceful stop timeout (typically 10 seconds).
- Docker gives up and sends
SIGKILL(exit 137). - In-flight PDF renders are violently severed mid-write, resulting in corrupted files and interrupted HTTP requests.
3. The Solution: Enter tini
To solve both zombie process accumulation and signal propagation, you need an init process acting as PID 1.
While full init systems like systemd or upstart are bloated and inappropriate for minimal Docker images, tini (krallin/tini) was designed specifically for containers:
- Tiny binary footprint: ~20 KB.
-
Single purpose: Registers as PID 1, spawns your target command as its child, forwards all incoming signals (
SIGTERM,SIGINT, etc.) to the child process and process group, and reliably reaps any adopted zombie children.
Container PID Namespace with tini
└─ PID 1: tini --
└─ PID 7: node index.js
└─ PID 15: chromium
Why not just use docker run --init?
Docker supports an --init flag which injects a built-in tini binary into the container. However:
- Managed PaaS environments (like Render, Google Cloud Run, AWS ECS Fargate, or custom Kubernetes clusters) do not always allow you to pass arbitrary flags like
--initto the Docker engine. - Packaging
tinidirectly inside yourDockerfileguarantees that the container is self-contained and behaves consistently everywhere—on a developer's laptop, in CI pipelines, and across any production host.
4. How We Implemented Tini in html2pdf
Implementing tini in our production api/Dockerfile took just a few modifications.
Step 1: Install tini in the Runtime Stage
Our production image uses a multi-stage build: node:22-alpine for compiling TypeScript, and node:22-slim (Debian) for runtime dependencies (Chromium, C++ graphics libraries, fontconfig).
We added tini to the apt-get installation list:
# api/Dockerfile (Runtime Stage)
FROM node:22-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
tini \
chromium \
chromium-sandbox \
fonts-liberation \
# ... other shared libraries
&& rm -rf /var/lib/apt/lists/*
Step 2: Configure ENTRYPOINT and CMD
Next, we configured tini as the container ENTRYPOINT, passing -- to denote the end of tini options, followed by the normal CMD:
# tini acts as PID 1 to reap zombie Chromium sub-processes and forward signals cleanly.
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "index.js"]
Because ENTRYPOINT and CMD are combined in Docker's exec format, the container executes:
/usr/bin/tini -- node index.js
tini launches as PID 1, spawns node index.js as its immediate child, and sits quietly in the background.
5. What We Are Optimizing: The Impact
| Problem Area | Before tini (Node as PID 1) |
After tini (tini as PID 1) |
|---|---|---|
| Zombie Sub-processes | Crashed/exited Chromium helpers persisted as <defunct> entries. |
Instantly reaped on SIGCHLD. Zero zombie buildup. |
| PID Resource Leaks | Long-running containers under high load risked kernel PID exhaustion (EAGAIN). |
Clean process table even after 100,000+ conversions. |
| Container Shutdowns | Ignored SIGTERM, hanging until Docker forced SIGKILL after 10s. |
SIGTERM forwarded instantly to Fastify and Chromium. Clean, graceful exit in <1 second. |
| Deployment Reliability | Active conversions risk corruption on sudden SIGKILL during redeploy. | Inflight jobs get time to finish or gracefully abort. |
| Image & CPU Overhead | N/A | +23 KB to the image size; 0.0% CPU overhead. |
6. Takeaways for Containerized Node & Browser Workloads
If your container does any of the following:
- Spawns child processes (
child_process.fork,child_process.spawn,execFile). - Runs headless browsers (Puppeteer, Playwright, Selenium).
- Executes CLI tools dynamically (ffmpeg, ImageMagick, pandoc).
Do not let Node.js or Bash be your PID 1 without an init helper.
Using tini is a minimal, low-risk, high-return infrastructure upgrade:
- Two lines in your
Dockerfile. - Bulletproof process lifecycle.
- Zero surprises in production.
Top comments (0)