Disclosure: RenderScreenshot is our product. wkhtmltoimage is free, open-source software. We compare it with our API, and also point to self-hosted Chromium options, because some readers should not pay for an API at all.
wkhtmltoimage (and the imgkit libraries that wrap it in Python and Ruby) renders HTML with a WebKit engine from Qt 4. That engine stopped receiving updates long ago. HTML written for a 2026 browser, with flexbox gap, CSS grid, custom properties or object-fit, renders wrong or not at all. The usual fix is to replace the engine with Chromium, either one you run yourself or one behind an API such as RenderScreenshot's HTML to image API.
This page shows the evidence, maps every wkhtmltoimage option, and gives you shims that keep your existing call sites. The PDF side of the same project is covered in the wkhtmltopdf alternative guide.
The evidence: what is inside the engine
The project's own status page (read on 2026-10-15) states it plainly:
- Qt 4, which wkhtmltoimage uses, has not been supported since 2015.
- The WebKit inside it has not been updated since 2012.
- "Do not use wkhtmltopdf with any untrusted HTML": it can lead to a complete takeover of the server.
- For pages that use dynamic JavaScript, the maintainer recommends Puppeteer instead.
What a WebKit frozen in 2012 means for your HTML: the unprefixed flexbox and CSS grid specs were finished and shipped in browsers after that engine stopped changing. The table below is what to expect from an engine of that age. It is not the result of a render test, so run the test page below with your own wkhtmltoimage to see the real output on your setup.
| CSS feature | Expect in wkhtmltoimage | Effect on your HTML |
|---|---|---|
Old flexbox (display: -webkit-box, -webkit-box-flex) |
Works | Only the 2009 flexbox syntax lays out |
Modern flexbox (display: flex, gap, justify-content) |
Missing | display: flex layouts do not lay out |
CSS grid (grid-template-columns) |
Missing | Grid is ignored |
object-fit |
Missing | Images stretch instead of cropping |
Custom properties (var(--brand)) |
Missing | Values that use variables are dropped |
border-radius, -webkit-transform |
Works | Rounded corners and prefixed transforms work |
A test page you can run in two minutes
Save this as modern.html. Each block uses one feature that a current browser supports and that the table above predicts will fail.
<!doctype html> <html> <head> <meta charset="utf-8"> <style> :root { --brand: #4f46e5; } body { font: 16px/1.4 system-ui, sans-serif; margin: 24px; width: 760px; } .row { display: flex; gap: 16px; margin-bottom: 16px; } .row > div { flex: 1; padding: 12px; background: var(--brand); color: #fff; } .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 16px; } .grid > div { padding: 12px; background: #e0e7ff; } .thumb { width: 240px; height: 120px; object-fit: cover; display: block; margin-bottom: 16px; } .calc { width: calc(100% - 48px); padding: 12px; background: #fde68a; } </style> </head> <body> <div class="row"><div>flex 1</div><div>flex 2</div><div>flex 3</div></div> <div class="grid"><div>grid 1</div><div>grid 2</div><div>grid 3</div></div> <img class="thumb" alt="" src="https://picsum.photos/id/1015/600/600"> <div class="calc">calc() width</div> <p id="js"></p> <script> const items = [1, 2, 3].map((n) => `item ${n}`); document.getElementById('js').textContent = `ES2015 ran: ${items.join(', ')}`; </script> </body> </html>
Render it both ways:
# wkhtmltoimage wkhtmltoimage --width 800 modern.html wk.png # RenderScreenshot (Chromium) curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ --data "$(jq -Rs '{html: ., viewport: {width: 800, height: 600}, capture: {mode: "full_page"}}' modern.html)" \ --output chromium.png
In Chromium, the three flex boxes sit side by side, the grid has three columns, the image is cropped, and the last line says "ES2015 ran". Compare that with wk.png. If your templates only use tables, floats and inline styles, you may see no difference at all, and that is useful to know too.
Option mapping
wkhtmltoimage option names are from its man page (0.12.6). Our names are from the content input reference and the parameter pages.
| wkhtmltoimage | RenderScreenshot | Notes |
|---|---|---|
| input file or URL | html or url |
Send the HTML string, not a file path |
--width |
viewport.width |
wkhtmltoimage treats width as a guideline (--disable-smart-width makes it strict); ours is exact |
--height (default: page height) |
viewport.height |
No --height means full height: use capture.mode: "full_page" |
--crop-x, --crop-y, --crop-w, --crop-h |
capture.clip {x, y, width, height} |
|
-f, --format |
output.format |
png, jpeg, webp, pdf |
--quality |
output.quality |
|
--transparent |
output.transparent |
PNG or WebP |
--zoom |
viewport.scale (1 to 3) |
Chromium scales the device pixel ratio instead of zooming the layout |
--javascript-delay |
wait.delay |
Better: wait.for_selector or wait.until: "networkidle" |
--window-status |
wait.for_selector |
Set an attribute instead of window.status |
--run-script |
page.scripts |
|
--user-style-sheet (local path) |
page.styles (CSS string or URL) |
|
-n, --disable-javascript |
block.resources: ["script"] |
|
--no-images |
block.resources: ["image"] |
|
--custom-header |
network.headers |
|
--cookie |
network.cookies |
|
--username, --password |
network.auth (type: "basic") |
|
--enable-local-file-access, --allow |
Not applicable | Inline local assets as data URIs, or host them |
Drop-in shims
Python: replace imgkit
imgkit.from_string(html, path, options) and imgkit.from_url(url, path, options) become the same calls on this module. Change import imgkit to import wk_shim as imgkit and keep the rest.
"""Drop-in replacement for imgkit.from_string / imgkit.from_url backed by Chromium. Accepts the same wkhtmltoimage option names imgkit passes through (with or without the leading dashes) and writes the image to output_path. """ import os import requests API = "https://api.renderscreenshot.com/v1/screenshot" def _opt(options, name, default=None): for key in (name, f"--{name}"): if key in options: return options[key] return default def to_renderscreenshot(source: dict, options: dict, output_path: str) -> dict: body = dict(source) width = int(_opt(options, "width", 1024)) # fallback if no --width was passed height = _opt(options, "height") body["viewport"] = {"width": width, "height": int(height) if height else 800} if not height: # no --height: wkhtmltoimage renders the full page body["capture"] = {"mode": "full_page"} if all(_opt(options, f"crop-{k}") is not None for k in ("x", "y", "w", "h")): body["capture"] = {"mode": "viewport", "clip": { "x": int(_opt(options, "crop-x")), "y": int(_opt(options, "crop-y")), "width": int(_opt(options, "crop-w")), "height": int(_opt(options, "crop-h"))}} fmt = (_opt(options, "format") or output_path.rsplit(".", 1)[-1]).lower() body["output"] = {"format": {"jpg": "jpeg", "bmp": "png", "svg": "png"}.get(fmt, fmt)} if _opt(options, "quality") is not None: body["output"]["quality"] = int(_opt(options, "quality")) if _opt(options, "transparent") is not None: body["output"]["transparent"] = True if _opt(options, "zoom"): body["viewport"]["scale"] = min(max(float(_opt(options, "zoom")), 1), 3) if _opt(options, "javascript-delay"): body["wait"] = {"delay": min(int(_opt(options, "javascript-delay")), 10_000)} if _opt(options, "run-script"): body["page"] = {"scripts": [_opt(options, "run-script")]} blocked = [] if _opt(options, "disable-javascript") is not None or _opt(options, "n") is not None: blocked.append("script") if _opt(options, "no-images") is not None: blocked.append("image") if blocked: body["block"] = {"resources": blocked} headers = _opt(options, "custom-header") # imgkit: [("Name", "value"), ...] if headers: body["network"] = {"headers": dict(headers)} return body def _render(source, output_path, options): resp = requests.post(API, json=to_renderscreenshot(source, options or {}, output_path), headers={"Authorization": f"Bearer {os.environ['RS_API_KEY']}"}, timeout=60) resp.raise_for_status() with open(output_path, "wb") as f: f.write(resp.content) return True def from_string(html, output_path, options=None): return _render({"html": html}, output_path, options) def from_url(url, output_path, options=None): return _render({"url": url}, output_path, options)
Ruby: replace IMGKit
# Minimal IMGKit-compatible class backed by Chromium: # ChromiumKit.new(html_or_url, width: 800, quality: 85).to_jpg require 'net/http' require 'json' class ChromiumKit API = URI('https://api.renderscreenshot.com/v1/screenshot') def initialize(source, options = {}) @source = source @options = options.transform_keys { |k| k.to_s.delete_prefix('--').tr('_', '-') } end def to_img(format = :png) res = Net::HTTP.start(API.host, API.port, use_ssl: true, read_timeout: 60) do |http| http.post(API.path, request_body(format).to_json, 'Authorization' => "Bearer #{ENV.fetch('RS_API_KEY')}", 'Content-Type' => 'application/json') end raise "render failed: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess) res.body end def to_png = to_img(:png) def to_jpg = to_img(:jpg) def to_file(path) File.binwrite(path, to_img(File.extname(path).delete('.').to_sym)) end def request_body(format) o = @options body = @source.match?(%r{\Ahttps?://}) ? { url: @source } : { html: @source } body[:viewport] = { width: (o['width'] || 1024).to_i, height: (o['height'] || 800).to_i } body[:capture] = { mode: 'full_page' } unless o['height'] # wkhtmltoimage renders full height by default body[:viewport][:scale] = o['zoom'].to_f.clamp(1, 3) if o['zoom'] body[:output] = { format: format.to_s == 'jpg' ? 'jpeg' : format.to_s } body[:output][:quality] = o['quality'].to_i if o['quality'] body[:output][:transparent] = true if o['transparent'] body[:wait] = { delay: [o['javascript-delay'].to_i, 10_000].min } if o['javascript-delay'] body[:page] = { scripts: [o['run-script']] } if o['run-script'] body end end
In a Rails app, replace IMGKit.new(html, quality: 80).to_jpg with ChromiumKit.new(html, quality: 80).to_jpg. If you render ERB templates into HTML first, keep that step; only the engine changes.
Node.js: replace the child process
Node projects usually spawn wkhtmltoimage with an argument array. This function takes the same array.
// Replace `wkhtmltoimage [options] input output` (child_process) with one HTTP call. // `args` uses the same CLI flags, so existing option arrays keep working. import { writeFile } from 'node:fs/promises'; export function argsToBody(input, args) { const flag = (name) => { const i = args.indexOf(`--${name}`); return i === -1 ? undefined : args[i + 1]; }; const has = (name) => args.includes(`--${name}`); const body = /^https?:\/\//.test(input) ? { url: input } : { html: input }; const height = flag('height'); body.viewport = { width: Number(flag('width') ?? 1024), height: Number(height ?? 800) }; if (!height) body.capture = { mode: 'full_page' }; // wkhtmltoimage renders the full height by default const crop = ['x', 'y', 'w', 'h'].map((k) => flag(`crop-${k}`)); if (crop.every((v) => v !== undefined)) { const [x, y, width, h] = crop.map(Number); body.capture = { mode: 'viewport', clip: { x, y, width, height: h } }; } const fmt = (flag('format') ?? 'png').toLowerCase(); body.output = { format: fmt === 'jpg' ? 'jpeg' : fmt }; if (flag('quality')) body.output.quality = Number(flag('quality')); if (has('transparent')) body.output.transparent = true; if (flag('zoom')) body.viewport.scale = Math.min(Math.max(Number(flag('zoom')), 1), 3); if (flag('javascript-delay')) body.wait = { delay: Math.min(Number(flag('javascript-delay')), 10_000) }; if (flag('run-script')) body.page = { scripts: [flag('run-script')] }; const blocked = []; if (has('disable-javascript') || args.includes('-n')) blocked.push('script'); if (has('no-images')) blocked.push('image'); if (blocked.length) body.block = { resources: blocked }; return body; } export async function wkhtmltoimage(input, args, outputPath) { 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(argsToBody(input, args)), signal: AbortSignal.timeout(60_000), }); if (!res.ok) throw new Error(`render failed: ${res.status} ${await res.text()}`); await writeFile(outputPath, Buffer.from(await res.arrayBuffer())); }
The shims follow the parameter names in our content input reference and the parameter pages. They were not run against the live API for this page, and the wkhtmltoimage command in the test-page section was not run either. Check one shim with your own key and a few of your real option sets before you swap the import everywhere.
Things that change when you switch engines
- Pages get taller or shorter. Chromium wraps text and lays out flexbox differently, so full-height captures change size. Update any code that assumes a fixed output height.
- Fonts change. wkhtmltoimage used the fonts installed on your server. A hosted renderer uses its own font set. Load web fonts in your HTML (Google Fonts or self-hosted with
@font-face) so the output does not depend on either machine. - Local files stop working.
file://paths and--enable-local-file-accesshave no equivalent in a hosted service. Inline small assets as data URIs and host the rest. window.statuswaits. If your templates setwindow.status = 'ready'for--window-status, set an attribute instead and wait for it withwait.for_selector.
Cost: self-hosted Chromium vs an API
wkhtmltoimage is free, and so are Chromium-based tools you run yourself. Our prices are from /pricing, checked on 2026-10-15.
| Images per month | wkhtmltoimage | Self-hosted Chromium (Puppeteer or Playwright) | RenderScreenshot |
|---|---|---|---|
| 1,000 | $0 + your server | $0 + a server with enough memory for Chromium | Starter, $10 (2,500 credits) |
| 10,000 | $0 + your server | $0 + the same server, plus queueing and restarts | Growth, $40 (12,000 credits) |
| 100,000 | $0 + your server | $0 + more servers, concurrency limits, monitoring | More than Scale's 60,000 credits ($100); contact us |
Chromium needs far more memory than wkhtmltoimage, so a box that ran wkhtmltoimage comfortably may not run several Chromium instances at once. That operational cost, not the license, is the real price of self-hosting. The Puppeteer vs Playwright comparison includes the memory we measured per browser.
When to keep wkhtmltoimage, or go self-hosted instead
- Keep it if your templates are simple (tables, floats, inline styles), the HTML is fully trusted, the output looks right today, and the process runs sandboxed. An old engine that renders your known templates correctly is not an emergency.
- Go self-hosted Chromium if you must render inside your own network, process data that may not leave it, or produce very large volumes on hardware you already pay for.
- Use an API if you want Chromium output without running Chromium. Create a free key, run the test page above, and swap one import.