How to Set a Timeout for HTML-to-PDF Conversion in Ruby
Set the right timeout for Grover, Wicked PDF, or PDFKit by separating browser, request, conversion, and process deadlines.
The timeout depends on the renderer behind your Ruby code. With Grover, set convert_timeout for PDF conversion in milliseconds, and configure request_timeout or launch_timeout when fetching the page or starting Chromium is the slow stage. With Wicked PDF or PDFKit, Ruby starts an external wkhtmltopdf process, so a hard deadline requires child-process management rather than a single shared gem option.
Do not treat one timeout value as universal. Measure template generation, browser startup, asset requests, PDF conversion, and the surrounding HTTP or job deadline separately.
Choose the timeout that matches the renderer
| Renderer | Primary control | Units | What it bounds |
|---|---|---|---|
| Grover | convert_timeout |
Milliseconds | PDF conversion stage |
| Grover | request_timeout |
Milliseconds | Fetching page content and assets |
| Grover | launch_timeout |
Milliseconds | Launching the browser |
| Grover | timeout |
Milliseconds | General timeout; documented example uses 0 for no timeout |
| Wicked PDF/PDFKit | Wrapper and child process | Your code’s units | The wkhtmltopdf subprocess and the Ruby call that waits for it |
Grover: configure conversion, request, and launch limits
Grover exposes separate options for the browser lifecycle. Its README shows this configuration shape (the numbers are an example of millisecond units, not a universal production recommendation): Grover documentation.
Grover.configure do |config|
config.options = {
timeout: 0,
launch_timeout: 3_000,
request_timeout: 1_000,
convert_timeout: 30_000
}
end
Use convert_timeout when Chromium has loaded the document but PDF creation is taking too long. Use request_timeout when the page or its assets are slow to respond; it takes precedence over the general timeout for requests. Use launch_timeout when starting Chromium is the bottleneck. Confirm the option names against the Grover version installed in your application.
Set options for one conversion
pdf = Grover.new(
html,
launch_timeout: 5_000,
request_timeout: 10_000,
convert_timeout: 30_000
).to_pdf
File.binwrite("invoice.pdf", pdf)
Separate application work from renderer work
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
html = ApplicationController.render(
template: "invoices/show",
assigns: { invoice: invoice }
)
template_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
renderer_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
pdf = Grover.new(
html,
request_timeout: 10_000,
convert_timeout: 30_000
).to_pdf
renderer_seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - renderer_started
Rails.logger.info(
template_seconds: template_seconds,
renderer_seconds: renderer_seconds
)
File.binwrite("invoice.pdf", pdf)
This tells you whether to optimize database/template work or adjust a renderer-stage limit. A general timeout: 0 disables Grover’s general timeout in the documented example; it does not mean the operation should finish immediately.
Wicked PDF and PDFKit: bound the wkhtmltopdf process
Wicked PDF and PDFKit invoke the external wkhtmltopdf executable. Their wrappers, the child process, and the HTTP request that called your Ruby code can all have different deadlines. The project documentation does not establish one universal conversion-timeout setting shared by both gems: Wicked PDF, PDFKit.
Ruby’s Timeout API
require "timeout"
begin
pdf = Timeout.timeout(30) do
PDFKit.new(html).to_pdf
end
File.binwrite("report.pdf", pdf)
rescue Timeout::Error
Rails.logger.error("PDF generation exceeded 30 seconds")
raise
end
Timeout.timeout accepts seconds, including fractional values, and raises Timeout::Error by default. Ruby’s documentation cautions: “For that reason, this method cannot be relied on to enforce timeouts for untrusted blocks.” It should not be presented as a guaranteed way to kill an external renderer: Ruby Timeout documentation.
Use Open3 when a hard child-process deadline is required
For a hard deadline, start the command yourself (or use the wrapper’s documented command-building hooks), monitor the child PID, send TERM, then KILL if needed, reap the child, close pipes, and remove temporary files. The exact arguments vary by your wkhtmltopdf installation.
require "open3"
require "timeout"
require "fileutils"
def run_wkhtmltopdf(input_path, output_path, seconds: 30)
stdout = stderr = status = nil
Open3.popen3("wkhtmltopdf", input_path, output_path) do |stdin, out, err, wait_thr|
stdin.close
begin
Timeout.timeout(seconds) do
stdout = out.read
stderr = err.read
status = wait_thr.value
end
rescue Timeout::Error
pid = wait_thr.pid
Process.kill("TERM", pid) rescue nil
begin
Timeout.timeout(5) { wait_thr.value }
rescue Timeout::Error
Process.kill("KILL", pid) rescue nil
wait_thr.value
end
raise
ensure
out.close unless out.closed?
err.close unless err.closed?
end
end
unless status&.success? && File.file?(output_path) && File.size?(output_path)
raise "wkhtmltopdf failed: #{stderr}"
end
output_path
end
begin
run_wkhtmltopdf("/tmp/report.html", "/tmp/report.pdf", seconds: 30)
rescue Timeout::Error
FileUtils.rm_f("/tmp/report.pdf")
raise
end
In production, ensure the child is reaped even on exceptions, pipes cannot deadlock, partial PDFs are deleted, and temporary input files are cleaned up. If your gem already owns the process, inspect its implementation and extension points before replacing it.
Diagnose which clock expired
- Template clock: measure database queries, rendering, and HTML construction before invoking the PDF engine.
- Launch clock: for Grover, inspect
launch_timeoutwhen Chromium startup is slow. - Request clock: inspect DNS, TLS, page responses, fonts, images, scripts, and other assets; configure Grover’s
request_timeout. - Conversion clock: use
convert_timeoutfor the final PDF stage. - Process clock: for
wkhtmltopdf, inspect the child PID, exit status, and stderr. - Service clock: compare all of these with Rails/Rack, reverse-proxy, web-server, and job-runner deadlines. A proxy can time out while a worker continues running.
Common failures and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Grover fails before the page loads | Browser launch exceeded its limit | Check Chromium availability and set an appropriate launch_timeout. |
| Page loads, but conversion expires | PDF generation is the slow stage | Set convert_timeout; reduce document complexity and large resources. |
| Assets never finish loading | Unreachable URLs, slow services, or blocked network access | Inspect renderer logs and asset URLs; set request_timeout and fix connectivity. |
| PDFKit hangs in development | A single server process is waiting for the renderer while the renderer requests assets from that same blocked process | Run multiple server workers or embed resources. Increasing the conversion timeout does not fix this deadlock. |
Timeout::Error but wkhtmltopdf remains |
Ruby interrupted the waiting call without guaranteeing child termination | Track the PID, send TERM, follow with KILL when necessary, reap it, and remove partial output. |
| Truncated or stale PDF is returned | Old output file survived a failed attempt | Write to a unique temporary path and validate exit status and nonzero file size before publishing it. |
| Untrusted HTML reaches the renderer | HTML can request internal addresses or expensive resources | Sanitize input and restrict renderer network access as well as execution time. |
Timeout design for web requests and background jobs
Short documents can be generated inline when the total work fits inside every surrounding deadline. For long or user-controlled documents, enqueue a job, store status, and let the client poll or receive a notification. Set the renderer deadline below the job deadline so cleanup can finish before the worker is terminated. Keep separate metrics for template, launch, request, conversion, and queue time.
Performance, reliability, and cost notes
- Measure representative documents: page count, CSS complexity, JavaScript, fonts, image sizes, and remote assets all affect duration.
- Cache or embed stable assets when appropriate, but keep cache invalidation explicit.
- Use unique temporary directories and clean them in
ensureblocks. - Retry only transient launch or network failures, with a bounded retry count; do not blindly retry deterministic invalid HTML.
- Keep renderer versions consistent across development, CI, and production.
- For untrusted content, combine timeouts with input sanitization and network restrictions.
- Do not claim a timeout value is safe for every application without measuring that application’s workload.
Or skip the browser setup
ScreenshotNeo provides a website capture API that can return PNG, JPEG, WebP, or PDF from one GET request. It handles the browser setup and can capture a URL as a PDF:
require "net/http"
require "uri"
uri = URI("https://api.screenshotneo.com/v1/shot")
uri.query = URI.encode_www_form(
access_key: ENV.fetch("SCREENSHOTNEO_API_KEY"),
url: "https://example.com/invoice"
)
response = Net::HTTP.get_response(uri)
unless response.is_a?(Net::HTTPSuccess)
raise "ScreenshotNeo request failed: #{response.code} #{response.body}"
end
File.binwrite("invoice.pdf", response.body)
See the ScreenshotNeo API documentation for request options. The same endpoint can be called with cURL, Python, or Node.js:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/invoice -o invoice.pdf
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://example.com/invoice"}, timeout=90)
r.raise_for_status()
open("invoice.pdf", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/invoice' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo request failed: ${res.status}`);
require('fs').writeFileSync('invoice.pdf', Buffer.from(await res.arrayBuffer()));
Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed, and response headers identify the page verdict and billing status. ScreenshotNeo also provides an MCP server so Claude, Cursor, and other MCP clients can take screenshots. The Free plan includes 1,000 shots per month with no card; paid plans start at $5 for 3,000 shots.
Create a free ScreenshotNeo account.
FAQ
Are Grover timeout values seconds or milliseconds?
Grover’s documented options use milliseconds. Ruby’s Timeout.timeout uses seconds.
Does increasing a timeout fix missing images?
No. First verify that the renderer can resolve the asset URLs and that the server is not deadlocked waiting for its own requests.
Can I use one timeout for the whole PDF request?
You can impose an outer application deadline, but keep renderer-stage and child-process deadlines separate so failures can be diagnosed and cleaned up.
When should PDF generation move to a job?
Use a background job when rendering can exceed the web request or proxy deadline, or when documents contain untrusted or highly variable content.
Is a zero Grover timeout a recommended production setting?
The README documents timeout: 0 as disabling the general timeout. Choose explicit limits based on measured workload and surrounding service deadlines.


