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.
We wanted something more concrete than dates, so we looked inside the official macOS build of 0.12.6-2 from the project's packaging releases. A browser engine stores the names of the CSS properties it understands as plain strings. We listed the strings in libwkhtmltox.0.12.6.dylib and searched for property names.
| CSS property name | Found in the engine? | Effect on your HTML |
|---|---|---|
-webkit-box-orient, -webkit-box-pack, -webkit-box-flex |
Yes | Only the old 2009 flexbox syntax works |
flex-direction, flex-wrap, justify-content |
No | display: flex layouts do not lay out |
grid-template-columns |
No | CSS grid is ignored |
object-fit |
No | Images stretch instead of cropping |
backdrop-filter, -webkit-filter |
No | No blur or color filters |
border-radius, transform |
Yes | Rounded corners and transforms work |
The same library contains the version string 4.8.7, which matches the patched Qt 4.8 that the status page describes. This is static inspection, not a render test: we could not execute the x86-64 macOS binary on our Apple Silicon test machine. Run the test page below with your own wkhtmltoimage to see the rendered result.
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, avif, 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())); }
Tested on 2026-10-15 with Node 22.18.0, Python 3.14 (requests 2.34.2) and Ruby 4.0.7. Each shim converted a set of wkhtmltoimage options (width, quality, crop, zoom, JavaScript delay, custom header, no-images) and sent the request to a local mock of our API; we checked the logged bodies against our docs. The wkhtmltoimage command in the test-page section was not run, for the reason given above.
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.