wkhtmltoimage Alternative: Replace IMGKit With Chromium

wkhtmltoimage runs a 2012 WebKit with no flexbox gap, grid or CSS variables. See the evidence, and drop-in shims for imgkit (Python), IMGKit (Ruby) and Node.

By Simon Chiu · Updated

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-access have no equivalent in a hosted service. Inline small assets as data URIs and host the rest.
  • window.status waits. If your templates set window.status = 'ready' for --window-status, set an attribute instead and wait for it with wait.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.

Frequently asked questions

Is wkhtmltoimage still maintained?
No. The project's own status page says the Qt 4 it uses has not been supported since 2015 and that its WebKit has not been updated since 2012. The newest package we found on the project's packaging releases was 0.12.6-2.
Why does display flex or grid not work in wkhtmltoimage?
Its WebKit predates the standard flexbox and grid specs. It knows the old -webkit-box syntax, but the engine library contains no property names such as flex-direction, justify-content or grid-template-columns.
Is wkhtmltoimage safe for user-supplied HTML?
The maintainers say no. Their status page warns that untrusted HTML can lead to a complete takeover of the server, and recommends sanitizing input and sandboxing the process.
Do I have to change my imgkit calls?
Not if you use a shim. The Python and Ruby shims on this page accept the same option names and return the same kind of result, so you change one import.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.