The error-correction level only buys damage tolerance in the data region
Decoding a QR code looks binary: it reads or it does not. But more variables decide the result than you would expect - the error-correction level, which region the damage lands in, and how many pixels each module gets. This post measures those boundaries on one single decoding path.
A v1 code is 21×21=441 modules. After finder, timing and format information, 208 data cells stay flippable. Flipping only the data region, the failure point spreads out by level: L survives 3-4 cells and fails at 5; M 5, fails at 6; Q 6, fails at 7; H holds 9-12 and fails at 10-13. The level really does buy tolerance.
Change how the same code breaks and the conclusion flips. Flip all 10 cells of the two timing patterns and L/M/Q/H all still decode: timing cells carry no data, so destroying them does not eat the same amount of correction capacity. Every run here went through QR code decoder.
Position matters more than level
How large a white centre logo can sit tracks the level: L takes side 3, 2 % of the 441 modules; M and Q side 6, 36/441≈8.2 %; H side 8, 64/441≈14.5 %. Bigger codes take more cover: a v3 code of 29×29=841 modules holds side 8 (7.6 %), and a 37×37=1369 module code holds 11 (8.8 %).
Press that white block onto the top-left finder pattern and level stops mattering: H fails at side 1 while L holds to 3. Flip the 147 cells of the finder region one by one and H/Q often collapse at 1-3 cells, but L/M hold 2-7. Same code, same amount of damage, only the region changes, and the whole order reverses.
| Damaged region | L | M | Q | H |
|---|---|---|---|---|
| Data region (pool of 208) | 3-4 | 5 | 6 | 9-12 |
| Finder region (147 cells) | 2-7 | 2-5 | 0-2 | 0-3 |
| Timing region (10 cells) | All 10 flipped, still decodes | |||
| Format region (33 cells) | 7-11 | 8-9 | 6-9 | 6-11 |
The hard line is pixels per module
Export the same v1 code at different quiet zone widths and the failure point is not image size but pixels per module. All three sizes at 1 px per module fail: 29×29 with quiet 4, 25×25 with quiet 2, 21×21 with quiet 0. At 2 px per module everything succeeds, including 42×42 with a zero quiet zone. Shrinking 580 px to 58 px keeps 2 px per module, and the payload stays exact character for character.
Nothing bites again at the top end: the template has one proportional downscale at maxDim=1000, so 1015×1015, 1160×1160 and 2900×2900 all become 1000×1000, which is 34.483 px per module, and the non-integer still decodes. 740×740 and 888×888 never reach the cap and enter at their own size. The preview max-height:300px only affects display, canvas reads natural size, so a 900×900 image loses no pixels because the preview looks smaller.
Inversion comes from one parameter
jsQR is called once: no timeout, no multi-scale retry, no binarization step, and a null return goes straight to the failure panel. Whether anything can be rescued depends only on the option passed in. inversionAttempts:'attemptBoth' is the sole source of inversion: 4 white-on-black codes (L/M/Q/H) decode 4/4, and the same batch fed back with dontInvert gives 0/4. The inverted code on a #111111 dark background decodes too.
A native BarcodeDetector branch does exist, with both fallback exits for an empty result and for an exception. But feature detection is false in all four headless Chrome runs on this machine, so every call goes to jsQR: all 23 successful decodes (15 standard fixtures plus 8 real payloads) report jsQR as the engine. The native path is unreachable here, so this post gives no two-engine comparison numbers.
Formats behave the same way. The page lists PNG / JPG / WebP / GIF / BMP, the code only matches image/*, and all 15 fixtures are PNG. No GIF sample was ever built, so that line is only a text claim.
Decoding cannot validate the generator
Run codes from our QR code generator back through this same jsQR path and all 20 groups return null (5 payloads × L/M/Q/H, scale 10 and scale 4). The fault is not on the decode side; it is written into the generated matrix. The mask function (r+c)%2===0 should only touch the data region, but isFunction marks the three finder patterns alone, so timing, alignment and format cells get XORed along with them. On a v1 code the timing pattern on row 6 reads 10000 where the standard is 10101, and 24-34 of the 233 function cells are wrong (L 32, M 34, Q 24), while all 15 control fixtures decode.
The capacity table on the generator side has arithmetic errors too: the 36 level combinations from v1 to v9 report 2 bytes more than the standard, and the 4 combinations of v10 report 3 bytes less. Version selection reads cap >= byteLen + 2, which leaves 2 spare bytes, so 15 bytes stay on v1 and 16 bytes jump to v2. The largest payloads accepted are L 269, M 211, Q 149, H 117, and the first rejected are 270, 212, 150, 118.
No batching, and two defects
The file input has no multiple (measured .multiple as false) and drag-and-drop takes only files[0], so the second file onward is dropped quietly. Draw two codes side by side into one 600×300 image and rendering runs once, returning the right-hand code. Nothing anywhere checks file.size, so any claim about a maximum image size has no basis.
Two defects measured on the page. First: re-uploading clears the panel but not the stored result, so after a successful decode, a re-upload, a failing image and one click on copy, the screen shows the failure text while the clipboard receives the previous content and the toast stays empty. Second: the decode library comes from a CDN, and when it cannot load it throws Uncaught TypeError with panel innerHTML length 0 - neither the 372 of the success text nor the 194 of the failure text, just blank.
Quick answers
Q: Why does a QR code I generated not decode?
Do not blame the level first. Codes generated on this site return null in all 20 groups on this path, because the mask is XORed into the function cells. Check in the order that paid off in the measurements: pixels per module first, then whether the finder patterns or the timing region are covered, and only then whether the payload passes 269 / 211 / 149 / 117.
Q: Is a bigger image easier to decode?
No. The floor is pixels per module, not size: all three sizes at 1 px per module fail, while 42×42 at 2 px per module with a zero quiet zone works. Anything larger is only scaled down to 1000×1000, and the non-integer 34.483 px per module still decodes character for character.
Q: Do inverted codes on a dark background work, and how many files fit per run?
Inversion works: the white code on a #111111 background decodes. But it rests entirely on the single attemptBoth parameter, not on the engine itself. The count is a hard limit: one file and one result at a time, so images go through this path one by one.