Thursday, October 22, 2026

Aspect Ratio, DPI and Pixels: The Math Most People Get Wrong (2026)


 <!-- meta: Aspect ratio, DPI and pixel math explained: why 72 DPI can print sharp, how to size images correctly, and the formulas most guides get wrong. -->

<!-- slug: aspect-ratio-dpi-pixels-math -->

Why a file can be 300 DPI and still be rejected, and what to check instead

The email arrived on a Tuesday. A printer had rejected a 24-by-36-inch poster, and the stated reason was one word: DPI. The file looked sharp on screen and had been exported at 300 DPI, exactly as the template demanded. A quick check in an aspect ratio calculator would have exposed the problem in seconds.

The DPI was not the problem. The file was 2,400 by 3,600 pixels, which at 300 DPI is 8 by 12 inches. The order asked for 24 by 36, so every inch became three. Effective resolution dropped to 100 DPI, and the printer could see that in the pixels long before reading the label.

The mismatch was structural: aspect ratio, pixel count and DPI answer three different questions, and confusing them is how files get rejected, faces get stretched and "4K" footage ends up looking softer than 1080p.

This guide walks through the arithmetic, the exceptions, and the cases where the math says one thing and your software quietly does another. The tool handles the division. The judgment about which number you are allowed to change is still yours.

The short answer

Aspect ratio is the shape of an image, expressed as width to height and reduced to the smallest whole numbers. 1920x1080 and 1280x720 are the same shape: 16:9. Resolution is how many pixels fill that shape. DPI is a print instruction stored in the file, and it changes the size at which pixels are placed, never the amount of detail.

Three formulas cover most situations:

  • Print size: inches = pixels / DPI
  • Missing dimension: height = width x (ratio height / ratio width)
  • Effective DPI: DPI = pixels / inches

Everything else follows from those three lines, plus the fact that cropping, resizing and letterboxing all fit an image to a ratio while doing very different damage.

Aspect ratio is a relationship, not a size

1920x1080 is 16:9 because 1920 / 120 = 16 and 1080 / 120 = 9. 1280x720 is also 16:9: both divide by 80. 2560x1440 divides by 160. The pixel count changes by a factor of 2.25 between 720p and 1080p, and the shape does not move at all.

This is why answering "what is the aspect ratio?" with pixel dimensions is a category error. The ratio is what survives after you remove the size. Two files can be 6 megapixels and 24 megapixels and still be interchangeable inside a 16:9 timeline, because the timeline only cares about shape until it has to scale one of them.

The reduction is simple arithmetic: divide both numbers by their greatest common divisor. For 1920 and 1080 that is 120. For 3840 and 2160 it is 240. If the numbers do not reduce to anything tidy, the ratio is still valid; it is just not one of the named ones.

The 1366x768 problem. The greatest common divisor of 1366 and 768 is 2, so the ratio reduces only to 683:384. That gives a decimal of 1.778646 against 1.777778 for 16:9. The difference is less than one part in a thousand. It is close enough that people call it 16:9, and not close enough for a scaler to ignore.

Fit a 1366x768 image into a 1920x1080 frame by width and the scale is 1920 / 1366 = 1.40556, so the height becomes 768 x 1.40556 = 1079.47 pixels. Half a pixel short. Fit by height instead and the scale is 1080 / 768 = 1.40625, so the width becomes 1366 x 1.40625 = 1920.94 pixels. Nearly one pixel too wide.

Neither path lands on a clean edge. The scaler either leaves a sliver of background or resamples the whole image by a fraction it was not designed for. On a single frame the error is invisible. Across an encode, a slideshow or a print placed at a fixed size, it shows up as a soft edge, a thin bar or a slightly stretched face. "Close enough" describes perception, and a scaler has no perception.

That resolution exists because LCD panels were manufactured in that shape, not because any camera standard produced it. If a ratio came from hardware rather than a format, treat it as approximate.

DPI is metadata, not detail

A JPEG or TIFF can store a number called dots per inch. It sits in the file header as a suggestion. It does not describe how many pixels exist, and changing it does not add or remove a single one.

Open a 3000x2000 image in an editor, change the DPI field from 300 to 72 and save. The file is still 3000x2000. Every pixel is intact. What changed is the instruction to place 300 of them per inch versus 72 per inch. The first prints at 10 by 6.67 inches; the second prints at 41.67 by 27.78 inches. Same data, different scale.

This is why the DPI field is close to useless as a quality signal. A file labeled 300 DPI can contain 600x400 pixels. A file labeled 72 DPI can contain 6000x4000. The label describes intended placement, and anyone can type any number into it.

Screens make the vocabulary worse. Displays are measured in pixels per inch, and CSS borrowed the word "pixel" to mean something that is not a physical dot at all. Browsers lay out a 1080-pixel-wide image at 1080 CSS pixels regardless of monitor density. MDN's guide to image resolution covers the web side well.

Reserve DPI for one job: telling a printer or layout application how large to place a file whose pixel dimensions you already know.

Why 72 DPI can print sharp. Take a 6000x4000 photograph labeled 72 DPI. Printed 10 inches wide, it delivers 6000 / 10 = 600 DPI. The label said 72 and the print is extremely sharp, because the pixel count was generous and the physical size was modest.

Why 300 DPI can print blurry. Take a 600x400 image labeled 300 DPI. Its native print size is 2 by 1.33 inches. Stretch it to 8 inches wide and the effective resolution is 600 / 8 = 75 DPI. The label still says 300. The print is soft, because the pixels ran out.

The same inversion explains the 72-versus-300 folklore. 72 was a convention tied to early desktop screen density; 300 became a convention for commercial print viewed at arm's length. Neither number is a law. A billboard at 20 DPI can look flawless from a road, and a business card at 200 DPI looks fine in the hand while failing under a loupe.

Effective DPI is the only DPI that exists at output time: pixels divided by final inches. Check that number, not the metadata field.

The print formula: pixels / DPI = inches

Every print sizing question reduces to one division. Pixels divided by DPI gives inches. Rearranged, DPI equals pixels divided by inches, and pixels equal inches multiplied by DPI.

Worked examples, all with the same arithmetic:

  • 3000 px / 300 DPI = 10 inches
  • 2400 px / 300 DPI = 8 inches, so a 2400x3000 file is an 8x10 at 300 DPI
  • 3600 px / 300 DPI = 12 inches
  • 1080 px / 300 DPI = 3.6 inches, which is why an Instagram square is not a print file
  • 3000 px / 150 DPI = 20 inches, the same file at half the density

Now run it backward. A 12-inch print at 240 DPI needs 12 x 240 = 2880 pixels. An 8x10 at 300 DPI needs 2400x3000. A 24x36 poster at 150 DPI needs 3600x5400, which is 19.4 megapixels.

The number that matters is the one you get after dividing. A printer asking for "300 DPI at final size" is really asking for enough pixels: final width in inches times 300, and final height in inches times 300.

Common ratios and the missing-dimension math

RatioDecimalTypical pixelsCommon use
1:11.0001080x1080Social squares, avatars, product grids
4:50.8001080x1350Instagram and Facebook feed portraits
2:30.6672000x3000Book covers, posters, full-frame portraits
3:21.5003000x200035mm photography, print enlargements
4:31.3334000x3000Phone cameras, tablets, presentations
5:41.2502500x2000Large-format prints, some monitors
16:91.7781920x1080Video, YouTube, desktop wallpapers
9:160.5631080x1920Stories, Reels, TikTok, vertical video
21:92.3332520x1080Cinema-style ultrawide displays

One footnote to the table: 2560x1080 is widely sold as 21:9 but is actually 64:27, or 2.370.

Finding the missing dimension. The ratio is a multiplier. If you know the width and want the height, multiply by height divided by width. If you know the height and want the width, multiply by width divided by height.

  • 16:9 at 1600 wide: 1600 x 9 / 16 = 900
  • 9:16 at 1920 tall: 1920 x 9 / 16 = 1080
  • 4:5 at 1080 wide: 1080 x 5 / 4 = 1350
  • 3:2 from a 4000-wide file: 4000 x 2 / 3 = 2666.67, rounded to 2667

Check any result by dividing: 1600 / 900 = 1.7778, which is 16 / 9. If the check fails, the multiplier was inverted. That is the single most common error in this entire subject.

Cropping, resizing and letterboxing

Three operations, three different costs.

Cropping removes pixels from the edges and keeps the rest at full density. A 4000x3000 file (4:3) cropped to 16:9 at full width becomes 4000x2250: 750 rows removed, or 3 million pixels, a quarter of the image. Nothing is resampled, so the remaining detail is untouched. The cost is compositional, because whatever lived in those rows is gone.

Resizing changes the pixel count. Proportional resizing preserves the ratio, and detail is lost or interpolated depending on direction. Non-proportional resizing changes the ratio, which stretches circles into ellipses and makes faces look wrong in a way viewers notice instantly even when they cannot name it.

Letterboxing keeps every pixel and adds bars to fill the difference. A 4:3 image in a 1920x1080 frame fills the height at 1080 and needs 1080 x 4 / 3 = 1440 pixels of width, leaving 240 pixels of bar on each side. Pillarboxing is the vertical-bar case; letterboxing usually refers to horizontal bars. Either way, no content is lost and no detail is invented.

Choose by asking what matters: composition (crop), file size and delivery constraints (resize), or completeness (letterbox). If you find yourself stretching, you have picked none of the three.

Social safe zones: one 9:16 file, three different cuts

A 1080x1920 vertical asset is the standard 9:16 shape, and each placement that accepts it treats it differently.

A feed post cropped to 4:5 shows 1080x1350. Center that crop and you lose (1920 - 1350) / 2 = 285 pixels from the top and 285 from the bottom. A headline at the very top of a story design disappears in feed.

Stories and Reels overlay interface elements on the same canvas: a progress bar and account name near the top, a caption, music line and call to action near the bottom, and a column of buttons on the right. Exact heights change with app versions and devices, so treat published figures as estimates.

The practical fix is a safe zone. Keep text and logos inside a centered band that survives every crop, for example 1080 wide by roughly 1420 tall, leaving about 250 pixels at the top and bottom. Everything outside that band should be background, not information.

This is not a design preference. It is the same subtraction as the print formula, applied to a different output size.

Upscaling, AI and bitrate: why more pixels is not more quality

Upscaling multiplies pixel count by interpolation. A 500x500 image enlarged to 2000x2000 has 16 times the pixels and exactly the same information. Edges soften, noise gets magnified and small text turns to mush.

AI upscalers do something different: they generate plausible detail. For a landscape that can be convincing. For a product label, a serial number, a legal disclaimer or a face, "plausible" is a liability. The model does not recover the original information; it invents a substitute. Never upscale text that must stay readable, and never upscale an image that will be used as evidence of what something looked like.

Video has a parallel trap. 4K is 3840x2160, about 8.3 megapixels per frame. 1080p is 1920x1080, about 2.07 megapixels. Four times the pixels, and the same budget of bits has to stretch across all of them.

At 25 frames per second, 1080p at 45 Mbps spends about 0.87 bits per pixel. 4K at 20 Mbps spends about 0.096, roughly nine times less per pixel. The 4K file has more pixels and less information in each one, which is why it can show blocking in motion, smeared grass and mushy shadows while the 1080p file looks clean. Resolution is the container. Bitrate is what you put in it.

How to use an aspect ratio calculator without fooling yourself

The aspect ratio calculator is the right tool for the division, but the inputs decide whether the answer helps.

Four checks before trusting any result:

  1. Enter the ratio as a ratio (16:9), not as a decimal someone rounded (1.78). Rounding introduces exactly the kind of error that produced the 1366 problem.
  2. Decide whether the output is a crop or a scale. A resize tool will scale; it will not turn a 4:3 photo into a 16:9 photo without either cropping or distorting.
  3. Compare the result to the source pixel count. If the calculator returns a width larger than the original, you are upscaling whether or not the tool says so.
  4. For deliverables, confirm the output in a ratio-to-resolution calculator or a dedicated 16:9 calculator and check it against the spec sheet.

The arithmetic is trivial. Knowing which quantity you are allowed to change is the actual skill.

FAQ

Is 1920x1080 the same aspect ratio as 1280x720?

Yes. Both reduce to 16:9: 1920 / 120 = 16 and 1080 / 120 = 9; 1280 / 80 = 16 and 720 / 80 = 9. Only the pixel count differs, by a factor of 2.25.

Does changing DPI change image quality?

No. DPI is metadata describing intended print placement. Changing it does not add, remove or alter pixels. Quality changes only when pixel dimensions change or when the file is compressed again.

What DPI should I use for print?

300 DPI at final size is the standard for handheld material. Large pieces viewed from a distance can go lower: 150 DPI is common for posters, and billboards can be far lower. The real requirement is pixels divided by inches.

How do I calculate print size from pixels?

Divide the pixel dimension by the DPI. A 3000-pixel-wide file at 300 DPI prints 10 inches wide; the same file at 150 DPI prints 20 inches wide. Do the same for height.

Why is my 300 DPI image blurry when printed?

Because the pixel count is too small for the physical size. A 600x400 file labeled 300 DPI is only 2 inches wide natively. Stretched to 8 inches it prints at 75 effective DPI, and the label cannot change that.

What is the difference between DPI and PPI?

DPI counts printer dots per inch; PPI counts image pixels per inch. Print workflows use DPI loosely to mean the pixel density delivered to the page. For sizing purposes the formula is the same.

Can I turn a 16:9 image into 9:16 without cropping?

Not without cropping or letterboxing. The shapes are different, so something has to give: pixels from the sides, or bars at the top and bottom. Stretching is the third option and the only one that is always wrong.

Is 4:5 or 9:16 better for Instagram?

It depends on placement. Feed images display at 4:5, so a 9:16 asset gets cropped there. Stories and Reels use 9:16 and add interface overlays. If one asset must serve both, design inside the shared center band.

Does upscaling a video to 4K make it sharper?

Not in any meaningful way. Upscaling adds pixels without adding information, and if the bitrate stays fixed, each pixel gets fewer bits. A well-encoded 1080p file usually beats a low-bitrate 4K file from the same source.

How do I find the missing dimension for a ratio?

Multiply the known side by the ratio's other side over its matching side. For 16:9 with a known width, height = width x 9 / 16. Verify by dividing the result back out.

The bottom line

Aspect ratio is shape, resolution is quantity, DPI is an instruction. Confusing the three produces the two failures behind most rejected files: an image stretched to fit, and an image with too few pixels for its final size.

The fix is arithmetic you can do on a phone. Divide pixels by DPI to get inches. Multiply a known side by the ratio to get the missing one. Check the effective DPI at final size before you export, not after someone rejects the order.

And when a number looks wrong, check which quantity it actually measures. The label on the file is the least reliable thing in the workflow.

JSON That Looks Fine but Breaks Your App: 8 Real Mistakes (2026)


 <!-- meta: JSON validators catch syntax errors but miss duplicate keys, big integers, and BOM issues. See 8 real JSON mistakes and how to fix each one today. -->

<!-- slug: json-that-looks-fine-but-breaks-your-app -->

Your parser said valid. Production disagreed. Here is what the spec actually permits, what it quietly tolerates, and where a payload loses data without raising a single error.

The payload that passed validation

A nightly sync job pulls orders from a partner API, runs every response through a JSON validator, and writes the records into an internal warehouse. The validator returned green on each batch. The job ran for eleven days before anyone noticed that a handful of customers were seeing each other's order history.

Two separate defects were hiding in the same file. The partner's serializer emitted 64-bit order identifiers as raw numbers, and every identifier above 9,007,199,254,740,991 had been rounded on arrival. A second endpoint merged two config fragments and produced an object with the same key twice. The duplicate was syntactically legal. Both parsers involved accepted it. They simply disagreed about which value won.

Neither defect is exotic. Neither requires a malicious payload, a broken library, or a junior mistake. Both are ordinary consequences of treating "the JSON validator accepted it" as proof that the data is correct. The partner had run the same check for months, on the same payloads, and it had never once been wrong about the thing it was actually measuring.

That distinction is the whole article. Syntax validation answers a narrow question: does this text follow the grammar in RFC 8259? It says nothing about whether the values survived the trip, whether keys are unique, whether numbers fit in your runtime, or whether the shape matches what your code expects. The eight mistakes below are the ones that pass the first check and fail the second.

The short answer

A JSON validator checks grammar. It does not check meaning, precision, uniqueness, or encoding. Most production JSON bugs come from four places: characters and constructs that are illegal but visually familiar, values that exceed what JavaScript can represent, parser behaviour that the specification leaves undefined, and payloads that are structurally valid but semantically wrong.

If you remember one sentence, make it this: parsing is a syntax gate, and a JSON validator is a syntax gate with a nicer error message. Neither is a correctness check.

The practical defence is a two-stage pipeline. Stage one rejects malformed text. Stage two validates the parsed result against a schema that encodes your actual contract: required fields, types, ranges, formats, and what null means. Skipping stage two is how a file that "looked fine" reaches production intact and still corrupts your data.

1. Duplicate keys: legal syntax, undefined behaviour

{
  "account": "acme-eu",
  "plan": "starter",
  "plan": "enterprise"
}

The grammar allows this. RFC 8259 says names within an object should be unique, and then adds that when they are not, the behaviour of receiving software is unpredictable. That is specification language for "we are not going to save you."

In practice, most mainstream parsers keep the last occurrence, which is why the example above usually resolves to enterprise. Some keep the first. Streaming parsers, configuration loaders, and older library versions have historically done the opposite. Two services in the same pipeline can read the same file and disagree, and neither will report an error.

Duplicate keys rarely arrive on purpose. They come from shallow merges, where a base config and an override object are combined by spreading both into a new object; from templates that append a field without checking whether it already exists; and from human edits during an incident, when someone adds the corrected value above the old one instead of replacing it. In every case the file stays parseable, so nothing upstream objects.

The fix is to never emit duplicates and to reject them on ingest:

{
  "account": "acme-eu",
  "plan": "enterprise"
}

On the receiving side, use a parser mode that fails on repeated names. Python's json.loads accepts an object_pairs_hook that lets you detect them, Jackson has FAIL_ON_DUPLICATE_PROPERTIES, and several linters flag them before the file ever ships. JSON.parse cannot detect duplicates through a reviver alone, so if you are handling untrusted input in Node, add a dedicated duplicate-checking parser or a pre-parse scan.

2. Big integers past 2^53-1

JavaScript numbers are IEEE 754 doubles. They represent integers exactly up to 9,007,199,254,740,991. Past that, adjacent integers collapse into the same value:

const order = JSON.parse('{"id": 9223372036854775807}');
console.log(order.id); // 9223372036854776000

No exception, no warning. The identifier changed. Twitter and Discord both moved to string-encoded snowflake IDs for exactly this reason: their identifiers are 64-bit values that a JavaScript client cannot hold as a number.

The fix is to transport large identifiers as strings and convert deliberately:

{
  "id": "9223372036854775807"
}
const order = JSON.parse('{"id": "9223372036854775807"}');
const id = BigInt(order.id);

BigInt keeps full precision in JavaScript, but JSON.stringify throws on it, so keep it at the boundary rather than letting it leak into your object graph. If you must keep numbers as numbers, parse with a big-number-aware library that produces BigInt or a decimal type directly from the source text.

3. Trailing commas

{
  "name": "weekly-report",
  "rows": 1200,
}

This is invalid JSON. It is valid JavaScript, valid Python, and valid in most modern configuration formats, which is why it appears in hand-edited files constantly. The JSON grammar has no provision for a comma before a closing brace or bracket, and a strict parser will stop at the } with a message that rarely mentions commas.

{
  "name": "weekly-report",
  "rows": 1200
}

The same rule applies to arrays: [1, 2, 3,] is not JSON. If you are hand-writing payloads for a test suite, strip trailing commas before saving, or run the file through a repair tool such as the fix invalid JSON online utility. The error message is almost never the useful part; the position of the failure is.

4. Comments are not allowed

{
  "ttl": 300,
  // seconds, not minutes
  "retries": 3
}

JSON has exactly six structural characters and no comment syntax. A // line comment or a /* */ block comment makes the document invalid, no matter how reasonable it looks.

{
  "ttl": 300,
  "retries": 3
}

If you need annotated configuration, you are not writing JSON. JSONC, used by VS Code and TypeScript config files, adds comments and trailing commas. JSON5 adds comments, unquoted keys, single quotes, and more. Both are distinct formats with their own parsers, and both will fail if you feed them to something expecting strict JSON. Pick one deliberately and label the file accordingly, or move the explanation into a sibling _comment field that your schema allows.

5. A UTF-8 BOM at the start

Some Windows editors and exporters save UTF-8 with a byte order mark, the three bytes EF BB BF, which appear as an invisible \uFEFF before the opening brace. The JSON grammar does not permit it. Strict parsers reject the document at character zero with a message like Unexpected token \uFEFF in JSON at position 0.

The text looks correct in every editor. That is what makes this one expensive: the file is fine, the characters before the file are not.

const raw = fs.readFileSync("payload.json", "utf8");
const clean = raw.replace(/^\uFEFF/, "");
const data = JSON.parse(clean);

Better still, configure the producing system to write UTF-8 without a BOM. When you cannot control the producer, strip the mark at the ingestion boundary and log that you did, because a BOM usually signals that something upstream is writing files in a legacy encoding mode.

6. NaN and Infinity are not JSON

JSON.stringify({ score: NaN, ratio: Infinity });
// '{"score":null,"ratio":null}'

Neither NaN nor Infinity is a JSON number, and neither has a literal in the grammar. JavaScript makes this worse by silently converting both to null on the way out. A missing measurement and an undefined division become indistinguishable from a deliberate null, and the receiving code has no way to tell them apart.

{
  "score": null,
  "ratio": null,
  "score_status": "not_computed"
}

Decide explicitly what a non-finite result means in your domain and encode it: null with a companion status field, a string sentinel such as "NaN", or omission of the key entirely. Then make the schema enforce that decision. A number type in a schema will already reject null, so the null has to be intentional, not accidental.

7. Broken Unicode escapes and half surrogate pairs

Characters outside the Basic Multilingual Plane, including most emoji, are encoded in JSON strings as a surrogate pair:

{
  "reaction": "\uD83D\uDE00"
}

That pair is valid and decodes to a single code point, U+1F600. A lone half is a different story:

{
  "reaction": "\uD83D"
}

The grammar technically permits this escape, but the result is not a valid Unicode string. Parsers vary: some substitute the replacement character U+FFFD, some throw, some carry the lone surrogate into your database where it corrupts later output. The same failure appears when code slices a string by UTF-16 code unit and cuts a pair in half, which is easy to do with .slice() or .length in JavaScript.

The fix is to emit complete pairs, or the literal UTF-8 character, and to treat string truncation as a code-point operation rather than a byte operation:

const reaction = "\uD83D\uDE00";
console.log([...reaction].length); // 1

Spreading the string counts code points, not code units, so a truncation helper built on it will not split a pair. On the receiving side, reject strings containing unpaired surrogates before they reach storage.

8. Valid JSON, wrong data: why a JSON validator cannot save you

{
  "email": "ana@example.com",
  "created": "12/03/2026",
  "tags": ["billing"],
  "discount": null
}

Everything here parses. Nothing here is guaranteed correct. created might be 12 March or 3 December depending on locale conventions. discount: null might mean "no discount applies" or "the discount field was not loaded", and the difference matters to a billing calculation. tags might be expected as an object keyed by tag name, not an array. None of these are syntax errors, so no syntax checker will find them.

The null-versus-missing distinction deserves its own paragraph because it causes more quiet bugs than any other semantic issue. A consumer that checks truthiness sees the same outcome in both cases: undefined for a missing key, null for an explicit one, and both are falsy. Code that branches on truthiness treats "not loaded" as "no discount" and applies the wrong price. Code that branches on in or Object.hasOwn behaves differently again. Decide which one your contract uses for each field, write it into the schema with required, and make optionality a deliberate choice rather than an accident of serialisation.

This is the gap between "it parsed" and "it is correct". Closing it requires a schema:

{
  "type": "object",
  "required": ["email", "created", "tags"],
  "additionalProperties": false,
  "properties": {
    "email": { "type": "string", "format": "email" },
    "created": { "type": "string", "format": "date-time" },
    "tags": { "type": "array", "items": { "type": "string" } },
    "discount": { "type": ["number", "null"] }
  }
}

Note what the schema does: it makes created an unambiguous RFC 3339 timestamp, forces tags to be an array of strings, and requires the three core fields to exist while leaving discount optional-but-nullable. It still cannot tell you whether null means "no discount" or "not loaded"; that is a domain decision, and it belongs in documentation or a sentinel field.

Validate syntax first, then validate against the schema with a JSON validator that reports paths and failing keywords, not just a boolean. When a document is large and the error path is not obvious, load it into a tree viewer and inspect the actual structure rather than the text; collapsed nodes make type mismatches obvious in a way that raw lines do not.

Summary table

MistakeSymptomFix
Duplicate keysDifferent services read different valuesReject duplicates at parse time; emit unique keys
Integers above 2^53-1IDs change silently after parsingTransport as strings; convert with BigInt
Trailing commasParser fails at the closing braceRemove the final comma before } or ]
Comments// breaks strict parsersUse JSONC or JSON5 deliberately, or a _comment field
UTF-8 BOMUnexpected token \uFEFF at position 0Save without BOM; strip \uFEFF on ingest
NaN and InfinityValues arrive as null with no warningEncode non-finite results explicitly; enforce in schema
Half surrogate pairsReplacement characters or storage errorsEmit complete pairs; slice by code point
Semantically wrong dataParses cleanly, computes wrong answersValidate against a JSON Schema contract

FAQ

Is JSON with duplicate keys invalid?

The grammar permits it, so most validators will pass it. RFC 8259 only says names should be unique and that behaviour is unpredictable when they are not. Treat duplicates as a defect even though they are not a syntax error.

Why does JSON.parse change my large ID?

JavaScript parses every number as a double, which holds integers exactly only up to 2^53-1. Anything larger is rounded. Send the ID as a string and convert with BigInt if you need arithmetic.

Can BigInt be used with JSON.parse directly?

Not natively. JSON.parse has no big-integer mode, and JSON.stringify throws on BigInt values. Use a big-number-aware parser or keep the value as a string and convert at the boundary.

Does JSON allow trailing commas?

No. Neither objects nor arrays accept a comma before the closing brace or bracket. The same document is valid JavaScript, which is why the mistake is so common in hand-edited files.

Why can I not add comments to JSON?

Comments are not part of the grammar. JSONC and JSON5 add them, but they are separate formats. If a tool accepts comments, it is not parsing strict JSON, and its output may not be portable.

How do I fix "Unexpected token \uFEFF"?

The file starts with a UTF-8 byte order mark. Strip it with raw.replace(/^\uFEFF/, "") before parsing, or re-save the file as UTF-8 without a BOM in your editor or export settings.

What happens to NaN and Infinity in JSON.stringify?

Both become null. Neither value exists in the JSON grammar, and JSON.stringify substitutes null rather than throwing, so the loss is silent unless you check for it.

Is a lone "\uD83D" a valid JSON escape?

The escape is grammatically allowed, but it is not a valid Unicode string on its own. Parsers may substitute U+FFFD, throw, or pass the broken value through. Always emit complete surrogate pairs.

Why did a JSON validator pass a payload that crashed my app?

Syntax validation only checks the grammar. It does not check required fields, types, ranges, date formats, or identifier precision. Those require a schema and, for precision issues, a parser that preserves the original number text.

What is the difference between null and a missing key?

A missing key means the property is absent, which a schema can detect with required. An explicit null means the property exists with a null value. Conflating them hides whether data was unloaded or genuinely empty.

The bottom line

Every mistake in this list survives a syntax check. That is the pattern: the failures that reach production are the ones that parse. Duplicate keys exploit undefined behaviour, big integers exceed the runtime's numeric range, BOMs and comments and trailing commas exploit the gap between JSON and the languages that resemble it, and semantic errors exploit the gap between grammar and contract.

Build the habit of two gates. Gate one is syntax, run on every payload, with a clear error position. Gate two is a schema that encodes what your code actually assumes. Then treat the third gate, domain meaning, as something only a human or an explicit sentinel field can supply. A JSON formatter helps you see the structure while you work, but seeing it is not the same as proving it.

The payload that broke production was valid the entire time. That is exactly why it got through.

How QR Codes Actually Work (And Why Some of Them Stop Working)


 <!-- meta: How QR codes work: error correction levels, static vs dynamic codes, why printed codes stop working, and how to test a code before you print it. -->

<!-- slug: how-qr-codes-work-why-they-stop-working -->

The square on your table is not a picture of a link. It is a file cabinet, and sometimes someone else owns the key.


A restaurant owner named Dana printed 400 table tents in March. Each one carried a QR code that opened her menu, created in twenty minutes with a free QR code generator and no thought about what would happen next. By July, half of them were dead. Customers were tapping their phones against the laminated card, squinting, and then ordering from a beat-up paper menu the server kept in her apron.

Dana did what most people do: she blamed the printer. Then she blamed her phone. Then she called the company that made the menu app, and got a support ticket number and a promise.

The real answer was less dramatic. Her code was a dynamic QR code, which means the black-and-white square did not contain her menu address at all. It contained a short redirect address owned by her menu vendor. When Dana downgraded her plan in June to save forty dollars a month, the redirect stopped resolving. The ink on the table tents was perfect. The code was perfect. The link behind it was gone.

This is the most common QR code failure in the world, and it is invisible: nothing about the printed square changes when it dies. To understand why, you need to know what is inside those little black boxes.

The short answer

A QR code is a two-dimensional barcode that stores data directly in a grid of black and white modules, plus error-correction data that lets a scanner rebuild missing pieces. There are two kinds: static codes, which contain the destination data itself and cannot expire, and dynamic codes, which contain a short link that redirects through a vendor's server. Static codes survive forever because there is nothing left to shut off. Dynamic codes survive exactly as long as someone keeps paying for the redirect. Most real-world failures come down to that distinction, plus a handful of printing mistakes: too small, too low-contrast, or missing the blank margin around the edges.

What is actually inside a QR code

Every QR code is a grid of modules read as binary. A scanner reads rows and columns the way a grocery scanner reads a barcode, but in two dimensions at once, which is why a QR code holds far more data.

Here is the part almost nobody knows: the data is not stored the way you would type it. It gets encoded, then interleaved, then mixed with error-correction bits, then arranged in a specific pattern. The three big squares in the corners are finder patterns that tell the camera where the code is and how it is rotated. A smaller alignment square helps correct for distortion when a phone photographs a code at an angle or on a curved surface.

The practical takeaway for a business owner is this: a QR code is not an image of a URL. It is a container. Change what is in the container and the printed square must change too. That is the entire static-versus-dynamic story in one sentence.

Error correction: why a logo in the middle still scans

QR codes carry redundant data so they can still be read when part of the surface is damaged, dirty, or covered. There are four error correction levels, and the recovery capacity is roughly standard across implementations:

LevelRecovery capacity (approx.)Typical use
L (Low)~7%Clean digital displays, large high-contrast print
M (Medium)~15%Default for most general-purpose codes
Q (Quartile)~25%Codes with a logo, industrial or outdoor use
H (High)~30%Heavy logo coverage, harsh conditions, damaged surfaces

If you have seen a code with a logo punched out of the middle, this is why it still scans. At level H, roughly 30 percent of the code can be lost and mathematically reconstructed, so a logo covering 15 to 20 percent of the area fits comfortably.

The catch is the one almost every guide skips.

More error correction is not automatically better

To recover 30 percent of the data, you must store 30 percent more data, and those extra modules need physical space. An H-level code is therefore denser than an L-level code holding the same URL. Denser means smaller modules at a given print size, so the camera must resolve finer detail, and the code has to be printed larger to stay scannable from the same distance.

A code that scans perfectly at 2 centimeters with level L might need 3 centimeters at level H. On a business card, that difference can be the difference between working and failing. On a poster, it is irrelevant.

The rule of thumb: use the lowest level that survives your logo and your environment, then print bigger than the minimum. Maxing out error correction while shrinking the print creates codes that look robust and scan terribly.

Static vs dynamic: the distinction that decides whether your code dies

This is the part worth reading twice.

A static QR code contains the destination data inside the code itself. A URL, a phone number, a block of text, a WiFi password. The code is the data. There is no server in the middle, no account, no subscription. You can print it, laminate it, bury it in concrete, and it will still work in ten years because the phone is decoding the content directly from the ink. If your website changes, the old code breaks, and you have to print new ones. That is the tradeoff, and it is an honest one.

A dynamic QR code contains a short redirect URL, usually something like a vendor domain with a code suffix. When a phone scans it, it contacts the vendor's server, which looks up where that short code should send the visitor, and then forwards them. This is why dynamic codes can be edited after printing: the vendor changes the destination on their end, and your laminated table tent magically points somewhere new. It is also why dynamic codes expire. The redirect lives in a database that costs money to run. Stop paying, and the database entry goes away, and your printed code becomes a decorative square.

Neither is universally better. A static code is right when the destination will not change and you want permanence. A dynamic code is right when you need to change the destination later, or need scan analytics, and you accept that you are renting the redirect. What you must never do is assume a dynamic code is permanent.

The URL length problem (and why short links scan better)

Here is a detail that explains a lot of mysterious failures.

Longer data means more modules. More modules means a denser grid. A denser grid at a fixed print size means smaller modules, which the camera has to resolve at a greater distance. So the same physical sticker can be easy to scan with a short URL and nearly impossible with a long one.

Consider a typical long tracking URL:

https://www.example-restaurant.com/menu/2026/spring/specials?utm_source=table-tent&utm_medium=print&utm_campaign=spring-menu

That is 131 characters. Now the same destination through a short link:

https://ex.co/sp26

That is 18 characters. The short version produces a dramatically sparser grid at the same print size. In poor light, at a distance, on a smudged surface, it wins every time. This is not a marketing trick; it is physics, and shorter input is the cheapest fix available.

The same logic explains why a WiFi QR code with a long password and a verbose network name can be finicky, and why a vCard with a full name, title, phone, email, website, and physical address produces a surprisingly dense code. Every character costs modules. A vCard can easily push past 200 characters, and at business-card print sizes that gets tight fast. If your vCard code fails on older phones, trim fields before you blame the phone.

Why codes fail in the real world: quiet zone, contrast, and size

Most "the QR code doesn't work" complaints have nothing to do with the data and everything to do with printing.

The quiet zone is the blank margin around the code. The specification calls for a margin of about four modules on every side. Without that breathing room, the scanner cannot reliably tell where the code starts and the background begins. Designers crop it constantly to make the code look tidier, and that single decision causes an enormous share of failures. If your code sits flush against a colored border, add margin back.

Contrast matters more than color choice. Dark modules on a light background is the reliable pattern. Inverted codes, light modules on dark background, work on some scanners and fail on others. Low-contrast pairings like dark gray on light gray, or brand colors with similar luminance, are a coin flip. If the brand palette demands a colored code, keep the dark modules genuinely dark and the background genuinely light.

Minimum print size is the third silent killer. The safe floor is roughly 2 by 2 centimeters, about 0.8 inches, for a short URL scanned from close range. Longer data needs more. Codes on a poster viewed from three meters need to be considerably larger. A useful rule: the code should be at least one tenth of the expected scanning distance. If people scan from two meters away, the code should be at least twenty centimeters across.

Glossy lamination, curved surfaces, plastic sleeves, sunlight, and textured paper where the ink bleeds into the fibers all make these problems worse.

Quishing: when the code itself is the attack

A QR code gives the scanner no visible destination. That is its convenience and its vulnerability.

In a quishing attack, someone prints a malicious QR sticker and places it over a legitimate one. Parking meters, restaurant table tents, EV charging stations, and public poster boards are common targets because the real code is already expected there. The victim scans, lands on a convincing payment page, and enters card details, and nothing feels unusual because scanning to pay for parking is now normal behavior.

The defense is simple and almost nobody uses it. Most phone cameras preview the destination URL before opening it. On iPhone, the notification that appears after scanning shows the link text. On Android, the browser usually shows the address bar before the page finishes loading, and many camera apps surface the URL in the scan prompt. Look at it. If a parking meter code points to a domain that has nothing to do with the city or the parking operator, do not proceed. If a restaurant menu code opens an unrelated shortener with a random string, be suspicious.

The same rule applies to codes you did not place yourself. A sticker that looks raised, crooked, or layered over another sticker is worth treating as hostile.

Test with multiple phones, not just yours

Your phone is the worst possible test device: it is the one you built the code on, it is probably newer than average, and its camera has been staring at this specific code for twenty minutes.

Test on at least three devices: one recent iPhone, one recent Android, and one older or cheaper phone if you can find one. Test in the actual lighting conditions, not in a bright office. Test at the actual distance a customer will stand. Test after laminating, because a glossy surface changes reflections. And test with the phone's default camera app, not just a dedicated scanner app, because that is what most people use.

If all three pass in real conditions, you have a code that works. If only yours passes, you have a code that works for you.

Choosing a QR code generator that will not expire

If you need a permanent code that never expires, generate it with a static QR code generator that creates codes with no expiration. The destination lives in the ink, so there is no subscription to lapse and no server to go dark.

For general use, the free QR code generator covers URLs, text, phone numbers, and the common formats, with control over error correction and output size. If you are encoding a network, a dedicated WiFi QR code generator handles the credential format correctly so guests are not typing a 24-character password on a phone keyboard.

And if you are on the receiving end, unsure whether a code is safe, a QR code scanner that previews the destination lets you inspect the link before you commit to opening it. That is the habit that defuses quishing.

For the technical background, the Wikipedia article on QR codes covers the encoding structure, versions, and capacity tables in more depth than any marketing guide will.

Frequently asked questions

Do QR codes expire?

Static QR codes never expire. The data is stored inside the code itself, so there is no server involved and nothing that can be switched off. Dynamic QR codes can expire when the redirect service stops running, which typically happens when a subscription lapses or the vendor shuts down.

How much data can a QR code hold?

Capacity depends on the data type and error correction level. In practice, a QR code can hold roughly 3,000 characters of alphanumeric text or about 7,000 numeric digits at the highest capacity version. The largest versions are extremely dense and difficult to scan from any real distance, so practical codes stay well under 300 characters.

Why does my QR code stop working after a few months?

The most common cause is a dynamic code whose subscription ended. The second most common is a changed destination: the page the code points to was moved or deleted. The third is physical: the print faded, the surface got scratched, or a sticker was placed over it.

Can I change the link in a QR code after printing?

Only if the code is dynamic. A dynamic code points to a short redirect that the vendor controls, so the destination can be updated without reprinting. A static code contains the destination directly, so changing it requires generating and printing a new code.

Why does my QR code work on my phone but not on others?

Older cameras, lower-resolution sensors, and weaker autofocus struggle with dense codes. Your phone may also have cached the code during testing. Print larger, shorten the URL, raise the contrast, and restore the quiet zone, then retest on an older device.

What is the quiet zone and why does it matter?

The quiet zone is the blank margin around the code, ideally about four modules wide on every side. Scanners use it to locate the code's boundaries. Cropping it to make the design look cleaner is one of the most common causes of scan failure.

Does a logo in the middle break the code?

Not if the error correction level is high enough. At level H, roughly 30 percent of the code can be obscured and still recovered. A logo covering 15 to 20 percent of the area is generally safe at that level, provided the code is printed large enough to compensate for the added density.

Are QR codes safe to scan?

Most are. The risk is quishing, where a malicious sticker is placed over a legitimate code. Preview the destination URL before opening it, and be skeptical of codes on public infrastructure that point to unrelated domains.

Why do short URLs make better QR codes?

Fewer characters produce fewer modules, which produces a sparser grid at the same print size. Sparser grids are easier for cameras to resolve at a distance, on curved surfaces, and in poor light. Shortening a URL can turn an unscannable code into a reliable one without changing the print.

What is the minimum size for a QR code?

About 2 by 2 centimeters, roughly 0.8 inches, for a short URL scanned at close range. Longer data and greater viewing distance require larger codes. A reasonable rule is one tenth of the scanning distance.

The bottom line

A QR code is a container, not a picture. Static codes hold the data and last as long as the ink. Dynamic codes hold a redirect and last as long as the subscription. Everything else, the error correction level, the URL length, the quiet zone, the contrast, the print size, is a variable you control at design time and cannot fix after printing.

Dana's table tents were fine. Her code was fine. She had rented a redirect and stopped paying rent. If you take one thing from this, make it the question you ask before you print anything: does this code contain my destination, or does it contain a promise from someone else?

Why Your Image Compressor Isn't Working (And What Actually Shrinks a Photo)


 <!-- meta: Image compressor not working? Learn why resize-first order, format choice, chroma subsampling and target-size search actually shrink a photo. -->

<!-- slug: why-image-compressor-not-working -->

The slider is the last decision you make, not the first, and most of the bytes you are fighting were never the slider's to remove.

You have a product photo that needs to go on a landing page. The original is 6,000 pixels wide and weighs more than the page's entire JavaScript bundle. You open an online tool to compress image files, drag the quality slider down until the preview looks acceptable, export, upload, and it is still 900 KB. So you do it again, on the file you just exported. By the third pass the label edges are mushy and the file is barely smaller than after the first attempt.

That experience is the most common reason people conclude their compressor is broken. It almost never is. The problem is doing the right operation in the wrong order, on the wrong format, with settings that fight each other. A compressor can only remove what is left to remove after the structural decisions are made. Hand it a 6,000-pixel PNG with an embedded colour profile and you will still be shipping an oversized photograph to a browser that draws it 800 pixels wide.

Fixing this needs no expensive software. It needs the four decisions that actually control file size, made in the right sequence.

The short answer

Shrinking a photo is four decisions in a fixed order. Resize to the largest size the image is actually displayed at, because pixels you never show are bytes you never needed. Choose the container: JPEG or WebP for photographs, PNG only when you need genuinely lossless pixels or transparency. Strip the metadata you do not need, which costs nothing visually. Only then choose quality, ideally by searching for the lowest value that passes your own visual check rather than guessing.

Everything else follows: quality 100 to 85 feels almost free because the encoder discards detail eyes ignore, a PNG screenshot refuses to shrink because PNG preserves every pixel exactly, and re-compressing looks worse because lossy compression accumulates rather than stacks.

Get the order right and the slider becomes boring, which is the point.

Resize first, then compress: the order that matters most

Here is the mistake that wastes more quality than any other: compressing a 6,000-pixel image to a 100 KB budget, then displaying it in a 900-pixel slot.

Consider what the compressor is asked to do: preserve detail across six thousand pixels within 100 KB. That is a punishing budget per pixel, so it discards fine texture, softens edges, and introduces blocking. The browser then throws most of those pixels away when it scales the image down. You paid the quality cost at full resolution and discarded the benefit.

Resize first and the arithmetic changes. At 1,200 pixels wide, a 100 KB budget is comfortable. The encoder keeps real detail, gradients stay smooth, packaging text stays readable. The file looks better at the same size, because you gave the compressor a realistic job.

A practical rule: resize to roughly twice the CSS display width for high-density screens, capped at the source resolution. A hero image shown at 1,200 CSS pixels wants about 2,400 pixels of real data; beyond that you ship pixels no screen resolves. A 300-pixel thumbnail needs 600 pixels at most. When there are many files at once, a bulk image resizer handles the dimensions without a round trip through an editor.

Downscale from the original in one step where possible; repeated small resizes soften the image. Use a reasonable resampling filter, since nearest-neighbour produces jagged edges on photographs.

Why PNG screenshots are huge, and when PNG is the wrong format

A screenshot of a dashboard or a slide full of text often ends up as a multi-megabyte PNG. People assume the tool failed. It did not. PNG is lossless, storing pixel values exactly, and it does not use chroma subsampling, so every pixel keeps full colour resolution. A photograph of a forest contains millions of subtly different greens that compress poorly under those rules. A screenshot of flat text contains long runs of identical pixels, which PNG handles well, until you add a photo, gradient, or drop shadow and the file balloons.

PNG is right for transparency with clean edges, line art and text that would visibly degrade, exact pixel values for design assets, and files that will be edited again. It is wrong for photographs and photographic screenshots destined for a page where a lossy format would be indistinguishable.

The confusing case is a screenshot with text, because lossy compression produces ringing around sharp letterforms. A good PNG optimiser can still cut a surprising amount by removing metadata and recompressing pixel data more efficiently, without changing a single pixel. If you need to compress PNG without losing quality, look for that: lossless optimisation, not a quality slider.

The JPEG quality curve: the big wins are near the top

JPEG quality is not linear. It runs 0 to 100, but the visual difference between 100 and 90 is small while the size difference is large, and the difference between 60 and 40 is large while the saving is modest. That is why "just lower the quality" is bad advice and "lower it from 100" is good advice.

At 100 the encoder tries to preserve nearly everything, including fine noise nobody sees at display size. Dropping into the mid-to-high 80s discards that invisible detail and typically produces a much smaller file with no visible difference on a normal screen. It is the most reliable saving on a photograph, which is why experienced exporters set quality in the 80s and stop worrying.

Push lower and you enter a different regime. Somewhere below about 60, artefacts become structural: eight-by-eight blocks appear in smooth gradients, edges gain halos, fine texture turns to mush. For a photograph viewed full size that is usually unacceptable. For a blurred background behind text it may be fine. The useful question is not "what quality should I use" but "what is the lowest quality that still looks right where this will live".

Your preview window is a poor judge: it fits the image to your screen. Judge at 100 percent on the most detailed region, then again at final display size.

Chroma subsampling: why 4:4:4 loves text and 4:2:0 loves photos

Colour is stored as one luma channel and two chroma channels. Human vision is far more sensitive to brightness detail than to colour detail, so most photo formats store colour at lower resolution. That is chroma subsampling.

In 4:4:4 every pixel keeps its own colour. In 4:2:0 colour is stored at half resolution in both directions, cutting colour data to a quarter. For photographs this is nearly invisible, because colour changes slowly across skin, sky, and foliage; luma carries the detail your eye wants. For text and sharp coloured edges it is a disaster. A red letter on white has a hard colour boundary the encoder can no longer represent precisely, so you get colour bleed and fringing around glyphs.

This is why a JPEG screenshot can look worse than its file size suggests, and why the same compressor that flatters a portrait can ruin a UI capture. For text-heavy content, either use 4:4:4 and accept a larger file, or choose a different format.

WebP and AVIF handle sharp edges better at the same budget, but the principle stands: subsampling bets that colour detail does not matter. Right for photos, wrong for interfaces.

Metadata, ICC profiles and thumbnails are dead weight

A JPEG from a phone is not just pixels. It carries EXIF: camera model, lens, exposure, sometimes GPS. It may carry an ICC profile describing how colours should be interpreted, an embedded thumbnail generated by the camera, and XMP blocks or edit history.

None of that makes the photo look better in a browser, and all of it costs bytes. GPS coordinates in a published photo are also a privacy problem, which is reason enough to strip metadata even when the saving is small. An embedded thumbnail is a second, smaller copy travelling inside the file for no reason at all.

Stripping metadata is the one optimisation with no quality trade-off. Some tools do it by default; many preserve it because photographers want copyright and colour information intact. For web delivery, removing EXIF and the thumbnail is almost always correct. ICC profiles need care: if the image is wide-gamut, removing the profile can shift colours, so convert to sRGB first, then strip.

Why compressing twice compounds the damage

Lossy compression is not idempotent. Each pass re-encodes the output of the previous one, so the encoder approximates an approximation. Artefacts from the first pass become input for the second, and part of the budget goes into reproducing damage instead of the photograph.

The consequence is simple: never compress a file you already compressed. Need a different size or quality? Go back to the original. Keep originals in a folder you do not touch and treat exports as disposable. If a client sends a "small version" and asks for something smaller, request the original; re-compressing their export will look worse than compressing the source once.

The same applies to screenshots of compressed images, exports from chat apps, and previews downloaded from social platforms. Those have been through at least one lossy pass and sometimes several, so your quality ceiling is set by the worst file in the chain.

A subtler version: resizing an already-compressed image, then compressing again. Resampling softens, compression adds artefacts, and the second encode amplifies both. If the compressed file is all you have, encode once at a conservative quality and stop.

How to compress image to a target size (it's a search problem)

Most upload forms do not ask for a quality level. They ask for a maximum size: 200 KB, 500 KB, 1 MB. People respond by guessing, exporting, checking, and repeating until it fits. That works, but it is slow and usually overshoots or undershoots, leaving quality on the table or shipping something muddy.

The correct framing is that target-size compression is a search problem. File size rises monotonically with quality, so you can binary search: try a quality, measure, narrow the range. A few iterations land within a percent or two of the target, and a shell loop does it in seconds.

# Binary search JPEG quality to hit a target size (ImageMagick)
target=200000; lo=40; hi=95; src=photo.jpg
while [ $((hi - lo)) -gt 1 ]; do
  q=$(((lo + hi) / 2))
  magick "$src" -resize '1600x1600>' -strip -quality "$q" out.jpg
  size=$(wc -c < out.jpg)
  if [ "$size" -le "$target" ]; then lo=$q; else hi=$q; fi
done
magick "$src" -resize '1600x1600>' -strip -quality "$lo" out.jpg

The important part is not the loop. It is -resize and -strip before -quality, which is this article's argument in one command. With correct dimensions and no metadata, the search converges fast and beats a guessed value.

In Node, sharp exposes the same idea: resize, then .jpeg({ quality, mozjpeg: true }) or .webp({ quality }), checking .toBuffer() length in a loop. The API differs; the order does not.

FormatCompressionTransparencyBest forWatch out for
JPEGLossyNoPhotographs, hero images, product shotsText and sharp edges; artefacts below moderate quality
PNGLosslessYesLogos, UI, line art, exact pixelsPhotographs and gradients; very large files
WebPLossy or losslessYesGeneral web images, mixed contentOlder tooling; some editors still awkward
AVIFLossy or losslessYesModern web delivery, small filesSlower encoding; support on older devices

If you would rather not script it, a free image resizer handles dimensions, and a reduce image size in KB tool handles the target-size search.

WebP and AVIF beat JPEG at the same visual quality

At the same perceived quality, a modern encoder generally produces a smaller file than JPEG. That is not marketing; newer tools describe an image more efficiently. WebP borrows ideas from video compression, predicts blocks more cleverly, and supports transparency, which JPEG cannot do at all. AVIF goes further with a newer intra-frame codec that handles fine detail and gradients better, typically winning on size again at the cost of slower encoding.

The gap changes what you can afford. A photo that needs a painful JPEG quality to fit a budget can often ship as a comfortable WebP at the same size and look cleaner. Across an image-heavy site, that saving compounds on every page.

Support is the caveat, and it shrinks every year. Current major browsers handle WebP, and AVIF works in modern Chrome, Firefox, Safari, and Edge. Check your analytics: if a meaningful share of your audience is on old browsers or embedded webviews, serve a JPEG fallback through <picture>.

One caution: converting an already-compressed JPEG to WebP does not recover lost quality. Transcoding a damaged source preserves the damage. Convert from the original. For a container-by-container reference, MDN's image file type guide is worth bookmarking.

Upscaling never adds detail, and 1000 px often beats 4000 px

Two related misconceptions waste a lot of bandwidth.

The first is that upscaling improves an image. It does not. Enlarging a 1,000-pixel photo to 4,000 pixels produces more columns, but the information content is unchanged; the new pixels are interpolated guesses. AI upscalers make better guesses, and they can look impressive on faces and textures, but they invent plausible detail rather than recover real detail. For a product photo that is a liability, because the invention may not match the actual product. Upscale for print if you must, never to make a web image "higher quality".

The second is that a bigger file is a better web image. The browser draws your image into a box measured in CSS pixels. A 4,000-pixel file shown in a 500-pixel slot is scaled down, and the extra pixels are averaged away on a standard display. Meanwhile it costs several times the bytes and delays rendering.

This is why a 100 KB image at 1,000 pixels routinely beats a 300 KB image at 4,000 pixels. The smaller file was encoded at a comfortable bits-per-pixel ratio, so it kept clean edges; the larger one was squeezed to fit a budget and displayed small, so all anyone sees is the damage. Display size is the frame of reference. Encode for it.

FAQ

Why does my image get bigger after I compress it?

Usually an output format change. Saving a JPEG as PNG, or re-encoding with a different colour profile, can increase size despite the compression request. Check the extension and export settings.

Is it better to resize or compress first?

Resize first, always. Compression quality depends on how many pixels share the byte budget, so smaller dimensions give the encoder an easier job and preserve more visible detail.

How do I know what quality to use?

Judge at display size, not in a fit-to-window preview. Start high, step down until you can see a difference, then go back one step. With a hard size limit, binary search instead.

Why is my PNG screenshot so large?

PNG stores every pixel exactly and keeps full colour resolution, so photographic content and gradients compress poorly. For screenshots containing photos, a lossy or modern format will be far smaller.

Does compressing an image twice make it worse?

Yes. Lossy compression is applied to an already approximated image, so artefacts accumulate. Always re-export from the original rather than compressing a compressed copy.

What is chroma subsampling and should I care?

It stores colour at lower resolution than brightness. Fine for photos, bad for text and sharp coloured edges. If a screenshot looks fringed, subsampling is a likely cause.

Do WebP and AVIF really beat JPEG?

At the same perceived quality they usually produce smaller files. The gap is large enough to matter for image-heavy pages, but check audience support and keep a fallback if needed.

Does removing metadata change how the image looks?

Removing EXIF and thumbnails does not change pixels. Removing an ICC profile can shift colours, so convert to sRGB first, then strip.

Can AI upscaling make a small image usable?

It can make it look sharper, but the added detail is generated, not recovered. For product photography or anything where accuracy matters, that is a risk rather than a fix.

The bottom line

Your compressor is probably fine. What fails is the sequence around it. Resize to the size the image is actually displayed at, pick a format that matches the content, strip the metadata you do not need, and only then search for the lowest quality that holds up. That order turns an unpredictable fight with a slider into a repeatable process, and it explains every symptom people blame on bad tools: the PNG that will not shrink, the JPEG that looks smeared after three passes, the 4,000-pixel file that looks worse than the 1,000-pixel one.

The mental model to keep is that file size is a budget and pixels are what you spend it on. Spend pixels on display size, spend bytes on detail that survives, and refuse to pay for anything the viewer will never see.

What a JWT Really Reveals: Decoding Tokens and 5 Mistakes That Leak Data (2026)


 <!-- meta: A JWT decoder reveals the payload, not trust. Learn how tokens leak data, why alg=none and kid injection work, and how to verify claims safely. -->

<!-- slug: jwt-decoder-security-mistakes -->

A practical guide to reading JSON Web Tokens without mistaking readability for trust, and to the five mistakes that put real data at risk.

A developer is debugging a login failure. The API returns a token, the frontend rejects it, and the fastest way to see what is inside is to search for "JWT decoder," open the first result, and paste the token into the box. The page instantly renders a header, a payload, and a signature. It looks harmless. It is not. That token is a live credential, and it may carry an email address, a user ID, a tenant name, and a role. The site now has it, along with every proxy, log, and browser extension that saw the request. Nothing was "decrypted," because nothing was ever encrypted. The token was simply read, exactly as the specification intends. What follows is what a JWT reveals and why readability is not trust.

The short answer

A JWT is a signed, base64url-encoded container. Its header and payload are readable by anyone holding the string. The signature proves integrity only if you verify it with the right key and the right algorithm. Decoding tells you what the token claims. Verification tells you whether you should believe it. A common failure is collapsing those two steps into one, or verifying with settings an attacker can influence.

What a JWT is, and what decoding does not prove

A JWT has three parts separated by dots: header, payload, and signature. Each part is base64url encoded, which is base64 with + replaced by -, / replaced by _, and padding removed. Base64url is an encoding, not a cipher. It exists so binary signature bytes and JSON can travel safely through headers, URLs, and cookies. It provides no confidentiality whatsoever.

The signature is computed over base64url(header) + "." + base64url(payload), using the algorithm named in the header. That detail matters: the algorithm is attacker-controlled until you validate it. The payload is JSON. Registered claims such as iss and exp are defined by RFC 7519; others are private to your application. A compact JWT looks like three opaque blobs, but two are plain text in disguise.

Decoding is not verification

This is the sentence to tape to the monitor: decoding is not verification. A JWT decoder is a JSON viewer with extra steps. It can show you "role":"admin" in a token you just invented in a text editor, because the payload has no magical protection. Anyone can craft a payload, base64url-encode it, and put a garbage signature on the end. A decoder will happily display it.

Verification is a different operation. It recomputes the signature over the exact header and payload bytes, compares it in constant time, and checks that the algorithm used is one you expected. Then, and only then, do the claims become input worth considering. Even after a valid signature, you still have to validate exp, nbf, aud, and iss yourself or through a library that does it correctly.

Treat decoded values as untrusted until verification succeeds, including the header. Do not make routing or key-selection decisions from unverified header fields.

A decoded token, line by line

Here is a typical access token split into its readable parts. The signature is omitted here because it is binary noise in this context, but in the real token it is the third segment.

Header, base64url decoded:
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "2026-04-key-01"
}

Payload, base64url decoded:
{
  "iss": "https://auth.example.com",
  "sub": "user_8f21c",
  "aud": "billing-api",
  "exp": 1782350400,
  "iat": 1782346800,
  "nbf": 1782346800,
  "jti": "b7c1e9a4-2f0d-4a51-9a2e-6f1f0f5c9d11",
  "email": "dana@example.com",
  "role": "admin",
  "tenant": "acme-prod"
}

Three things jump out. The email and tenant are fully readable. role sits there, inviting a frontend to trust it. exp is a Unix timestamp in seconds, not milliseconds or an ISO date. Each is a place where implementations go wrong. Verification, by contrast, is short and explicit:

import jwt from "jsonwebtoken";

const claims = jwt.verify(token, publicKey, {
  algorithms: ["RS256"],
  issuer: "https://auth.example.com",
  audience: "billing-api",
  clockTolerance: 60
});

The algorithm list is a policy, not a hint. Issuer and audience are compared exactly, and the tolerance is small and deliberate. If any check fails, verify throws and no claim is trusted.

Mistake 1: trusting alg=none

The JWT specification defines none as a valid algorithm value for unsecured tokens. It exists for cases where integrity is provided by other means, and it is widely disabled in libraries for good reason. The classic attack is simple: take a valid token, change the header to {"alg":"none","typ":"JWT"}, strip the signature, and send it. If the server's verification path accepts whatever algorithm the header names, the token passes. The attacker has just promoted themselves by editing a JSON field.

The defense is to pin the expected algorithm on the verifying side and refuse everything else, including none and any algorithm you did not explicitly enable. Never let the header choose the verification strategy. An explicit allowlist makes the policy visible:

const verifiers = new Map([
  ["RS256", verifyRsa],
  ["ES256", verifyEcdsa]
]);
const verify = verifiers.get(header.alg);
if (!verify) throw new Error("algorithm not allowed");
verify(token, key);

A library that defaults to "use the header" is a library you must configure, not a library you can trust out of the box. The same rule applies to key type: an RSA key must not be usable as an HMAC secret, and an EC key must not be accepted where an RSA key is expected.

Mistake 2: HS256 versus RS256 key confusion

HS256 is HMAC with SHA-256. The signer and verifier share one secret. RS256 is RSA with SHA-256. The signer holds a private key and the verifier holds a public key. They solve different problems, and mixing them up is a well-known vulnerability class.

The confusion attack works like this. A service publishes its RSA public key so clients can verify tokens. The verifier is configured loosely and accepts either RS256 or HS256. An attacker takes that public key and uses it as the HMAC secret to sign a forged token with "alg":"HS256". If the verifier treats the same key material as an HMAC secret, the signature checks out, and the attacker never needed the private key.

The fix is structural. Pin the algorithm, keep verification keys separate from signing keys, and reject any token whose alg differs from the one configured for that issuer. If you are testing HS256 locally, verify it with an HS256 verification tool that takes the shared secret explicitly rather than inferring it from a key set. Key material and algorithm policy must be decided by the verifier, never by the token.

Mistake 3: kid header injection

The kid (key ID) header tells the verifier which key to use. That is convenient when keys rotate. It is also an injection point when implementations build a lookup from it. Two patterns recur. In the file-system variant, code constructs a path like keys/ + kid + .pem and reads it. A kid containing ../../../../etc/passwd or an absolute path can escape the directory. In the SQL variant, code interpolates kid into a query that fetches the key row, and a crafted value alters the query.

Both are ordinary injection bugs wearing a JWT costume. The header is unverified at the moment you need it to select a key, so it is fully attacker-controlled. Defenses are equally ordinary: map kid through a strict allowlist of known key identifiers, never concatenate it into a path or query, reject values outside a safe character set and length, and resolve keys from a fixed set scoped to the issuer you already trust.

Mistake 4: ignoring the claims that matter

A signature proves the token was issued by someone holding the key. It says nothing about whether the token is still valid, who it was for, or whether it is being replayed. The registered claims are small, but each closes a specific hole.

ClaimMeaningWhat the verifier must do
issIssuer identifierCompare exactly against a configured, trusted issuer
subSubject, usually the user IDUse as the identity key after verification, never as a role
audIntended audienceReject tokens minted for a different service
expExpiration time (seconds since epoch)Reject at or after this time, with a small leeway
nbfNot valid beforeReject tokens used too early, with leeway
iatIssued atSanity-check age; useful for max-age policy
jtiUnique token IDUse for replay detection and revocation lists
scopeGranted permissions (common, not registered)Authorize per request, do not cache blindly

The frequent failure is checking exp and stopping there. A token issued by a staging issuer for an internal tool can be unexpired and still be the wrong token for your API. Audience checks stop a token minted for one service from being replayed against another. Issuer checks stop a rogue issuer from being accepted.

Clock skew, leeway, and the exp trap

exp, nbf, and iat are numeric dates in seconds. Verifiers compare them to their own clock, and clocks drift. A token minted on one host can appear to be issued in the future on another. Good libraries expose a leeway or clock tolerance setting, commonly a minute or so. That window is not slack for attackers; it is a deliberate accommodation for distributed systems. Pick a value based on measured drift, not convenience.

The trap is treating exp as a session timeout. It is a hard ceiling on the token's usefulness, not an idle timer. A user who is active all day still loses access when exp passes unless the client refreshes. Conversely, a stolen token remains usable until exp, no matter how quickly the user logs out, unless you add revocation. If you need to inspect a token's remaining life during debugging, a dedicated JWT expiry checker that runs locally avoids shipping the token somewhere else. And remember that exp in milliseconds, a common bug when generating tokens in JavaScript, produces a date tens of thousands of years away and effectively never expires.

Mistake 5: putting PII and roles in the payload

Because the payload is readable, anything in it is public to anyone who obtains the token. Email addresses, phone numbers, national IDs, internal hostnames, and entitlements leak through logs, browser history, support screenshots, error trackers, and proxies. Tokens also travel in places people forget: Authorization headers captured by observability tools, URLs copied into chat, and crash reports.

Roles are subtler. A role claim is a snapshot from issuance. If an administrator is demoted, their token still says admin until it expires; if a user leaves a tenant, the token still carries it. The real risk is an API that authorizes from the claim without checking current state. Keep authorization on the server, keep claims minimal, and treat a role as a hint to reconcile for sensitive actions.

JWTs are not sessions

A session is server-side state with an identifier the client holds. A JWT is a self-contained assertion, and that difference has consequences. Self-contained tokens are large: a few claims plus a signature run to hundreds of bytes, and group memberships can push past the 4 KB cookie limit. Large tokens are sent on every request, inflate headers, and can break cookie handling when they exceed browser or proxy limits.

They also cannot be invalidated by deleting server state, because there is none. Client logout only forgets the token; it remains valid until exp. Immediate logout across devices requires server-side tracking, which means reintroducing session state. Use short-lived access tokens, rotating refresh tokens, and a revocation mechanism. Do not call a JWT a session and then act surprised when logout does not log anyone out.

Use a JWT decoder locally, not on a random website

Pasting a production token into an arbitrary site hands a live credential to an unknown operator. Even if the site is honest, the token may be captured by analytics, request logs, or third-party scripts and persist in a cache you cannot clear. The same applies to browser extensions and "quick" command-line one-liners that ship the token to a remote API. Decoding needs no network access: base64url decoding is local.

Prefer a private JWT decoder that processes the token in the browser without transmitting it, or decode it locally. When you must use a hosted tool, use a synthetic token with the same shape, never the real credential. Redact signatures and identifying claims in screenshots. A JWT decoder online is fine for learning and test tokens; it is not fine for anything that grants production access.

Revocation: you cannot un-issue a JWT

A signed token is valid until it expires, and no amount of client-side deletion changes that. If a laptop is stolen, a token leaks, or an employee is terminated, the token remains usable for the remainder of its lifetime. This is the revocation problem, and there is no free fix.

The realistic options are short expiry, a denylist keyed by jti, and a per-user token version checked on each request. Short expiry limits the blast radius. A denylist gives precise revocation at the cost of a lookup per request. A token version is cheaper: bump it on logout-all or password change, and reject older tokens. Refresh tokens make the combination workable, because the long-lived credential can be rotated while access tokens stay brief and stateless. Choose deliberately; the wrong answer is assuming that "signed" means "revocable."

Frequently asked questions

Is a JWT encrypted?

Usually not. A standard signed JWT (JWS) is encoded and signed, not encrypted, so anyone with the token can read the header and payload. Encryption exists in the JWE format, but it is a different structure and is not what most people mean when they say JWT. If confidentiality matters, do not put secrets in the payload.

Can anyone decode my JWT?

Yes. Base64url decoding requires no key, no password, and no special tool. Anything you can paste into a decoder, an attacker can paste into a decoder too. The signature prevents modification, not reading.

What is the difference between decoding and verifying?

Decoding is a reversible formatting step that produces JSON. Verification recomputes the signature with a trusted key, checks the algorithm is the one you expect, and validates the time and audience claims. Only the second step produces trustworthy data.

Why does my token have alg set to none?

Either the issuer deliberately created an unsecured token, or something stripped the algorithm. In production, none should be rejected outright. A verifier that accepts it will accept any payload an attacker writes.

Should I store a JWT in localStorage or a cookie?

Both have tradeoffs. localStorage is readable by any script on the page, which makes XSS worse. Cookies can be marked HttpOnly, Secure, and SameSite, which reduces script access but introduces CSRF considerations. Neither choice fixes a token that is too long-lived or carries too much data.

How long should a JWT last?

Short enough that a leaked token has limited value. Many systems use minutes for access tokens and days or weeks for refresh tokens with rotation. The right number depends on your risk tolerance and your ability to revoke, not on a universal constant.

Can I revoke a JWT?

Not by itself. You can let it expire, deny it through a jti denylist, or reject it using a per-user token version. Each approach adds state or latency, which is the cost of revocation in a stateless design.

What is clock skew and how much leeway should I allow?

Clock skew is the difference between the issuer's clock and the verifier's. A small leeway, a minute or so, prevents valid tokens from being rejected at the exp and nbf boundaries. Measure your actual drift and keep the window as small as your infrastructure allows.

Is it safe to use an online JWT decoder?

Only for tokens that grant nothing. A production token is a credential; pasting it into a third-party site is equivalent to sharing a password. Use a local or in-browser decoder that does not transmit the token, and redact screenshots.

Why is my JWT so large?

Every claim adds bytes, and the token repeats on every request. Group lists, permission arrays, and profile data are common culprits. If it approaches cookie or header limits, move non-essential data server-side and keep only an identifier.

The bottom line

A JWT is a readable envelope with a tamper-evident seal. The seal is valuable only when you check it against the right key, the right algorithm, and the right claims. Decoding is a convenience for humans; verification is the security boundary. The five mistakes in this article all share one root cause: letting the token, or the person holding it, decide what the verifier should trust. Pin your algorithms, separate your keys, treat kid as hostile input, validate every registered claim, keep personal data out of the payload, and accept that revocation requires real state. Do that, and a JWT decoder becomes what it should have been all along: a debugging tool, not a breach.

Aspect Ratio, DPI and Pixels: The Math Most People Get Wrong (2026)

 <!-- meta: Aspect ratio, DPI and pixel math explained: why 72 DPI can print sharp, how to size images correctly, and the formulas most g...