"The QR code doesn't scan" is almost never a bug in the generator. It's usually a print size problem, and print size is simple math once you know what a QR code is made of. Here's the math, with numbers from the qrcode npm package.

A QR code is a grid of modules

Every QR code is a square grid of small squares called modules. The grid size depends only on the version, from 1 to 40:

modules per side = 17 + 4 × version 

Version 1 is 21 × 21, version 4 is 33 × 33, version 40 is 177 × 177.

Around the grid you need a quiet zone: empty space 4 modules wide on every side, according to DENSO WAVE, the inventor of the QR code. So the space a printed code really occupies is:

total modules = 17 + 4 × version + 8 

What the scanner cares about is not the size of the whole code but the size of one module. Everything below follows from that.

What decides the version

The generator picks the smallest version that fits your data at the error correction level you chose. Error correction lets a damaged code still scan: about 7% recoverable at L, 15% at M, 25% at Q and 30% at H. More correction means more redundant data, so a bigger grid.

I ran a few URLs through qrcode (QR.create(url, { errorCorrectionLevel })):

URL Length L M Q H
https://example.com/m 21 v2 (25) v2 (25) v3 (29) v3 (29)
https://www.example.com/p/123 29 v2 (25) v3 (29) v3 (29) v4 (33)
https://www.example.com/products/winter-jacket 46 v3 (29) v4 (33) v4 (33) v6 (41)
the same URL + ?utm_source=flyer&utm_medium=print&utm_campaign=winter2026 104 v5 (37) v6 (41) v8 (49) v10 (57)

The number in brackets is modules per side, without the quiet zone.

The UTM parameters alone take the code from 33 to 41 modules at level M. With the quiet zone that's 49 instead of 41 modules across, so on the same printed area every module gets about 16% smaller.

From modules to millimetres

module size = printed width / (modules + 8) 

The product URL above at level M is version 4: 33 modules plus 8 for the quiet zone makes 41.

Printed width (incl. quiet zone) 41 modules (no UTM) 49 modules (with UTM)
20 mm 0.49 mm 0.41 mm
25 mm 0.61 mm 0.51 mm
30 mm 0.73 mm 0.61 mm
40 mm 0.98 mm 0.82 mm

Bigger modules scan from further away and survive bad lighting, cheap printers, glossy paper and shaky hands. You'll often see a rule of thumb that the code should be about a tenth of the scanning distance. It's a rough guide, not a standard, but it points the right way. The starting sizes I use: 20-25 mm on a business card, 30-40 mm on a flyer, at least 50-70 mm on a poster, 100 mm or more in a shop window. I wrote up the practical side, with testing tips, here: https://www.dummyipsum.com/blog/qr-code-size-for-print

Three ways to get bigger modules without a bigger code

Shorten the data. Drop tracking parameters you don't need, or point the code at a short path that redirects to the long URL. The table shows how much that saves.

Don't overdo error correction. M is a sensible default for print. Use H when you put a logo in the middle, because the logo covers modules and the code relies on error correction to recover them.

Use uppercase where the server allows it. QR has an alphanumeric mode for 0-9, A-Z, space and $%*+-./:, which packs data much tighter than byte mode. A lowercase URL forces byte mode:

https://www.example.com/products/winter-jacket -> M: v4 (33) HTTPS://WWW.EXAMPLE.COM/PRODUCTS/WINTER-JACKET -> M: v3 (29) 

The scheme and host are case-insensitive, so they're safe. The path usually isn't, so this only works if your server treats the path case-insensitively or you set up an uppercase redirect.

Exporting a PNG that prints sharp

A QR code is made of hard edges. If one module ends up 7.2 pixels wide, the edges get anti-aliased into grey and the scanner has to guess. Keep a whole number of pixels per module.

The qrcode package does this for you with scale (pixels per module) and margin (quiet zone in modules):

const QR = require('qrcode'); await QR.toFile('code.png', 'https://www.example.com/products/winter-jacket', { errorCorrectionLevel: 'M', scale: 8, // 8 px per module margin: 4, // 4-module quiet zone }); // 41 modules × 8 px = 328 × 328 px 

At 300 dpi, 328 px prints at 27.8 mm. To hit a target size, work backwards: px = mm / 25.4 × dpi, divide by the total modules, round up to a whole number and recalculate the printed size.

If you draw the code on a canvas and scale it later, turn off smoothing (ctx.imageSmoothingEnabled = false, or image-rendering: pixelated in CSS). Otherwise the browser blurs it for you.

For print, SVG avoids the whole problem:

const svg = await QR.toString(url, { type: 'svg', errorCorrectionLevel: 'M', margin: 4 }); // viewBox="0 0 41 41": one unit per module, scales to any size without blur 

Never export a QR code as JPG. Its compression creates artifacts around the edges that you may not see, but the scanner will.

Test the printed thing

The math gets you close. The final check is a printout at real size, scanned from the real distance, with both an iPhone and an Android phone, in bad light. If you have to hunt for the right angle, make the modules bigger.

If you'd rather not write any code, this is the generator I built on these rules. It has PNG, SVG and PDF export, error correction levels, a quiet zone setting, logos, and a printable sheet with many numbered codes per page:

https://www.dummyipsum.com/qr-code-generator

What's the smallest QR code you've seen that still scanned reliably?