<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mindutil</title>
    <description>The latest articles on DEV Community by Mindutil (@li_yi_dcec96bf2647dec1b7e).</description>
    <link>https://dev.to/li_yi_dcec96bf2647dec1b7e</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4174928%2F40eeeb0e-a4ba-4e3c-9fe5-aed702539161.png</url>
      <title>DEV Community: Mindutil</title>
      <link>https://dev.to/li_yi_dcec96bf2647dec1b7e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/li_yi_dcec96bf2647dec1b7e"/>
    <language>en</language>
    <item>
      <title>Half of my AI directory is labeled "free". 88% of those listings were never claimed.</title>
      <dc:creator>Mindutil</dc:creator>
      <pubDate>Sat, 31 Oct 2026 00:36:14 +0000</pubDate>
      <link>https://dev.to/li_yi_dcec96bf2647dec1b7e/half-of-my-ai-directory-is-labeled-free-88-of-those-listings-were-never-claimed-53e4</link>
      <guid>https://dev.to/li_yi_dcec96bf2647dec1b7e/half-of-my-ai-directory-is-labeled-free-88-of-those-listings-were-never-claimed-53e4</guid>
      <description>&lt;p&gt;I run a directory with 4,323 AI tool listings. Every row has exactly one pricing label: free, freemium, or paid. On paper the split looks healthy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;free: 2,198 (50.8%)&lt;/li&gt;
&lt;li&gt;freemium: 1,425 (33.0%)&lt;/li&gt;
&lt;li&gt;paid: 700 (16.2%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a site whose main feature is "filter by price", those are the numbers I care about most. Then I asked a different question: how many of these rows has anyone actually confirmed?&lt;/p&gt;

&lt;p&gt;Each listing can carry a verified badge, which means the vendor went through a claim flow and said "yes, this is us". 614 of 4,323 do. That is 14.2%. The split by pricing tier is where it gets interesting:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Label&lt;/th&gt;
&lt;th&gt;Listings&lt;/th&gt;
&lt;th&gt;Verified&lt;/th&gt;
&lt;th&gt;Verified rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;td&gt;2,198&lt;/td&gt;
&lt;td&gt;261&lt;/td&gt;
&lt;td&gt;11.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;freemium&lt;/td&gt;
&lt;td&gt;1,425&lt;/td&gt;
&lt;td&gt;225&lt;/td&gt;
&lt;td&gt;15.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paid&lt;/td&gt;
&lt;td&gt;700&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;18.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Free listings are the least verified segment, paid listings the most. I am not going to pretend this is causal. This directory has produced 4 outbound clicks in total, so nothing here is statistically impressive. But the direction makes sense: a paid product has revenue, a support inbox, and someone whose job includes checking listings. A free tool often has one person, no budget, and no reason to care that a directory in another timezone has the wrong price attached to it.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is that "free" is the label readers trust most. It is the first filter people apply, and it is the one applied to the listings that nobody has checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the label actually comes from
&lt;/h2&gt;

&lt;p&gt;The pricing label is inferred, not declared. It comes from the text that arrived with the listing: vendor marketing copy, aggregator feeds, and the crawl itself. So "free" means "the page said free when it was crawled", not "there is a usable free tier with a published limit".&lt;/p&gt;

&lt;p&gt;That gap is easy to underestimate. While hand-writing 20 tool pages last week, I hit three failure modes that no automated pass had caught:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Version drift.&lt;/strong&gt; The Adobe Firefly entry still named Firefly 3. The current flagship is Firefly Image Model 5, two generations later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product merging.&lt;/strong&gt; One row had collapsed Nano Banana 2 (&lt;code&gt;gemini-3.1-flash-image&lt;/code&gt;) and Nano Banana Pro (&lt;code&gt;gemini-3-pro-image&lt;/code&gt;) into a single tool, with one set of limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conflicting prices.&lt;/strong&gt; Google AI Plus appeared at both $4.99 and $7.99 across 2026 sources. I wrote "unconfirmed" instead of picking the friendlier number.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three are the kind of error that only surfaces when a human opens the vendor's pricing page and reads it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unverified rows rot in predictable ways
&lt;/h2&gt;

&lt;p&gt;The 85.8% of rows without a verified badge are not all wrong. Most are just fine. But the drift follows patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stale names.&lt;/strong&gt; Products rename themselves faster than feeds update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merged or split products.&lt;/strong&gt; Two tools become one, or one tool becomes a tier of another, and the old row keeps collecting traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead tools.&lt;/strong&gt; 405 rows (9.4% of the directory) sit in a discontinued category. Some are genuinely shut down; others are dormant and might come back.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also a category-level version of the same problem. Of 92 category pages, 3 currently have zero tools: &lt;em&gt;New AI Tools&lt;/em&gt;, &lt;em&gt;Aixploria Selection&lt;/em&gt;, and &lt;em&gt;Programming&lt;/em&gt;. They still render, and they are still in the sitemap. That is a thin page I created by accident, and it is the sort of thing a crawler is right to ignore.&lt;/p&gt;

&lt;p&gt;Related: the average tool sits in 1.62 categories, and 2,100 of 4,323 rows (48.6%) are in more than one. Overlaps are normal, but they also mean a single bad row can produce several bad pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Freshness is a number, not a vibe
&lt;/h2&gt;

&lt;p&gt;The "new tools" page is the one most likely to lie by omission, so I started measuring it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;22 listings were published in the last 30 days.&lt;/li&gt;
&lt;li&gt;0 were published in the last 7 days.&lt;/li&gt;
&lt;li&gt;The newest row is dated 2026-10-18, which is 13 days old today.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a directory fed by crawling, that is not a disaster, but it does mean the page cannot honestly present a daily "fresh arrivals" feed. The useful metric is not "did the crawl succeed" but "how many rows did this run add or change" — a crawl that succeeds with zero additions is a silent outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three changes that followed
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Treat the pricing label as a claim.&lt;/strong&gt; Store the source and the date it was read next to the value. "Free, checked 2026-10-30" is a different statement from "free".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure verification per segment, not overall.&lt;/strong&gt; The headline 14.2% hides a real 11.9% versus 18.3% gap, and that gap tells you where to look first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prune and mark.&lt;/strong&gt; A discontinued category that is visible is better than dead rows scattered through live categories, and empty category pages should be noindexed until they have something on them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this fixes the underlying economics: free listings will always have the weakest data, because nobody is paid to keep them accurate. The best I can do is stop presenting an inferred label as a verified fact, and show readers which rows have been checked.&lt;/p&gt;

&lt;p&gt;The free list, such as it is: &lt;a href="https://aiverse.mindutil.com/free-ai-tools?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=sideproject" rel="noopener noreferrer"&gt;https://aiverse.mindutil.com/free-ai-tools?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=sideproject&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>sideprojects</category>
      <category>database</category>
    </item>
    <item>
      <title>I fact-checked the free tiers of 20 AI tools. Most listings were wrong.</title>
      <dc:creator>Mindutil</dc:creator>
      <pubDate>Fri, 30 Oct 2026 00:32:11 +0000</pubDate>
      <link>https://dev.to/li_yi_dcec96bf2647dec1b7e/i-fact-checked-the-free-tiers-of-20-ai-tools-most-listings-were-wrong-3l19</link>
      <guid>https://dev.to/li_yi_dcec96bf2647dec1b7e/i-fact-checked-the-free-tiers-of-20-ai-tools-most-listings-were-wrong-3l19</guid>
      <description>&lt;p&gt;I run a small AI tools directory - 4,323 entries, filterable by pricing. For the first month, almost every tool page was built from a single sentence of upstream text plus a template paragraph. That is the standard scaled-content setup, and it is wrong in ways that are easy to measure.&lt;/p&gt;

&lt;p&gt;So I picked the 20 highest-traffic pages (211K to 414K reads each in the source data) and rewrote them from primary sources: vendor pricing pages, docs changelogs, model cards, and - where the vendor site was unreachable from my network - two or three dated 2026 secondary sources cross-checked against each other. Every claim now carries a source URL and a fetch date.&lt;/p&gt;

&lt;p&gt;Three error classes came up over and over. None of them were typos.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Version drift: the listing describes a product two generations old
&lt;/h2&gt;

&lt;p&gt;The Adobe Firefly entry still said "Firefly 3". The current flagship image model is Firefly Image Model 5, announced at Adobe MAX in October 2025, with native 4-megapixel output and broad availability since early 2026. The page also described Firefly as an image tool, while the web app now covers video, audio, vector and Boards.&lt;/p&gt;

&lt;p&gt;This is not cosmetic. Firefly's paid tiers are published (Standard $9.99/mo with 2,000 credits, Pro $19.99 with 4,000, Pro Plus $49.99 with 10,000, Premium $199.99 with 50,000), and standard image and vector generations are unlimited on any paid plan - credits get spent on premium models at 10 per Image 5 generation and 100 per second of 1080p video. A reader who budgets against the old listing buys the wrong tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Model merging: two products collapsed into one row
&lt;/h2&gt;

&lt;p&gt;One entry mixed Nano Banana 2 (gemini-3.1-flash-image) with Nano Banana Pro (gemini-3-pro-image). They are different tiers with different prices. Per-image API pricing on the 2 line runs $0.067 at 1K, $0.101 at 2K and $0.151 at 4K, and Nano Banana 2.1 - GA on 2026-10-06 - roughly halves that to $0.0336, $0.0504 and $0.113.&lt;/p&gt;

&lt;p&gt;Merging them is how a directory ends up telling people a model costs twice what it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Confident numbers the vendor never published
&lt;/h2&gt;

&lt;p&gt;Google's free image-generation allowance is published as roughly 20 images a day on the basic tier, 50 on Plus, 100 on Pro and 1,000 on Ultra - with Google itself warning the caps change often. Fine. But the price of Google AI Plus showed up as both $4.99 and $7.99 in two independent 2026 sources, and I could not settle it. That page now says the price is unconfirmed instead of picking the nicer-sounding one.&lt;/p&gt;

&lt;p&gt;Same with Anthropic: Claude Opus 5.5's usage is governed by a rolling 5-hour cap plus a weekly cap, published as multiples of Pro, not absolute message counts. Anthropic does not print the numbers. So we do not either - and the page says why, instead of inventing "200 messages per 5 hours".&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules I now apply to every tool page
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The free tier is described as &lt;em&gt;what you can do&lt;/em&gt;, not as a credit count. "Unlimited text messages since 2026-08-10, metered images, deep research and voice" is useful; "free plan available" is not.&lt;/li&gt;
&lt;li&gt;Every number has a source URL and a fetch date, stored next to the text.&lt;/li&gt;
&lt;li&gt;Conflicts are written down as conflicts, with the date of each figure. A directory that resolves a disagreement by guessing is just a blog post with a sort button.&lt;/li&gt;
&lt;li&gt;Two pros and two cons, taken from the vendor's own failure notes where they exist. Google's model card, for example, is candid that small text blurs at 1K and long paragraphs go wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why bother, for a directory
&lt;/h2&gt;

&lt;p&gt;Because the only thing that separates a directory from a search result is the answer to "can I afford this, and will it do the thing". The pricing split across those 4,323 entries is 2,198 free, 1,425 freemium, 700 paid - which means most of the catalogue &lt;em&gt;is&lt;/em&gt; affordable, and the useful work is telling people where the wall actually is.&lt;/p&gt;

&lt;p&gt;I have done 20 of 150 top pages. The remaining 130 are templated, and I would rather ship them slowly than ship them wrong.&lt;/p&gt;

&lt;p&gt;If you want to check the argument against real listings, the free-tier filter is here - every row shows its pricing type, and the pages I rewrote carry the facts above:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aiverse.mindutil.com/free-ai-tools?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=topic" rel="noopener noreferrer"&gt;https://aiverse.mindutil.com/free-ai-tools?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=topic&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Corrections welcome, especially from people who maintain these tools - if a listing is out of date, send the pricing page that beats mine.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>database</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building a pricing-first filter for 4,300 AI tools (and what the data looks like)</title>
      <dc:creator>Mindutil</dc:creator>
      <pubDate>Thu, 29 Oct 2026 02:19:23 +0000</pubDate>
      <link>https://dev.to/li_yi_dcec96bf2647dec1b7e/building-a-pricing-first-filter-for-4300-ai-tools-and-what-the-data-looks-like-3pf4</link>
      <guid>https://dev.to/li_yi_dcec96bf2647dec1b7e/building-a-pricing-first-filter-for-4300-ai-tools-and-what-the-data-looks-like-3pf4</guid>
      <description>&lt;p&gt;A few weeks ago I started AIVerse, a directory of AI tools. The premise was simple: every "best AI tools" list I could find was either a paid placement farm or so stale that half the pricing was wrong. So I built the boring version instead — one that treats pricing and category data as something to be maintained, not written once.&lt;/p&gt;

&lt;p&gt;This post is about the data side, because that is where the surprises were.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the data
&lt;/h2&gt;

&lt;p&gt;Current state of the catalogue, pulled today rather than typed from memory:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pricing model&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;2,198&lt;/td&gt;
&lt;td&gt;50.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freemium&lt;/td&gt;
&lt;td&gt;1,425&lt;/td&gt;
&lt;td&gt;33.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid&lt;/td&gt;
&lt;td&gt;700&lt;/td&gt;
&lt;td&gt;16.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4,323&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;across 92 categories&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Category concentration is steeper than I expected. Four categories hold a quarter of everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Video Generators — 294&lt;/li&gt;
&lt;li&gt;Image Generators — 269&lt;/li&gt;
&lt;li&gt;Productivity — 253&lt;/li&gt;
&lt;li&gt;Developer Tools — 247&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The remaining 88 categories average about 37 tools each. 708 listings carry a 2026 publish date, which tells you how fast the supply side is still growing.&lt;/p&gt;

&lt;p&gt;One number I do not want to hide: only 614 listings have a verification level recorded. "Free" is therefore a claim taken from the vendor page, not something I confirmed by signing up. That gap is exactly the difference between a directory and a database, and it is the thing I am slowly paying down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that actually mattered
&lt;/h2&gt;

&lt;p&gt;While exporting every listing to check link health, I diffed the &lt;code&gt;websiteUrl&lt;/code&gt; field against vendor domains and found 30 rows pointing not at the vendor but at an affiliate redirect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;capcut → capcutaffiliateprogram.pxf.io/9LPy7W
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pxf.io&lt;/code&gt; is Impact's link domain; &lt;code&gt;partnerlinks.io&lt;/code&gt; is another one. The tracking URLs carried a referring sub-ID (&lt;code&gt;?s=...&lt;/code&gt;), which means every "Visit website" click on those 30 pages was crediting someone else's affiliate account. Worse, the descriptions on those same rows said things like "You can use CapCut online at capcutaffiliateprogram.pxf.io" — a templated sentence that turned out to be repeated across 2,084 pages of the site.&lt;/p&gt;

&lt;p&gt;Three fixes, in order of how much they mattered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reject tracking domains at every entry point.&lt;/strong&gt; There is one shared list, imported by the crawler, the CSV importer and the public submission form. Previously each path had its own validation and they had drifted apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolve the 30 existing rows&lt;/strong&gt; by following the redirect chain to the final vendor domain. Four needed manual judgment: one landed on a &lt;code&gt;/fr&lt;/code&gt; locale, one self-redirected in a loop, one dead-ended on the tracker, and one pointed at a completely different product page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip the templated sentence at render time&lt;/strong&gt;, so those pages stopped showing the tracker before the underlying data was cleaned.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How the pipeline works now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Crawl the upstream catalogue.&lt;/li&gt;
&lt;li&gt;Reject tracking domains and anything that is not a vendor-owned URL.&lt;/li&gt;
&lt;li&gt;Strip boilerplate — four known template markers. Whatever is left under 25 words gets queued for a human instead of being rendered.&lt;/li&gt;
&lt;li&gt;Hand-write the top 150 pages by traffic, four facts each: free tier limit, price tiers, two pros, two cons, and who it is for.&lt;/li&gt;
&lt;li&gt;Store that hand-written copy in a git-tracked JSON overlay that takes precedence over the crawled description at render time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is the one I would recommend to anyone building the same thing. My first attempt wrote copy straight into the production SQLite database through an admin form. That sounds fine until you notice the deploy hook restores the live database from a backup on every push — so the copy existed only on the server that was about to be overwritten. Content in git gets a diff, a review and a rollback. Content in a production table gets none of those unless you build them yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is still broken
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;9 of 150 pages written.&lt;/strong&gt; The queue orders by traffic, so this is the head of the distribution, but 94% of the hand-written coverage is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No affiliate links yet.&lt;/strong&gt; Zero of 4,323 listings have a commercial link. That is deliberate: I would rather send traffic to the vendor for free than send it to a tracker that does not pay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The traffic numbers are inherited, not measured.&lt;/strong&gt; They come from the source catalogue. The only numbers I trust are outbound clicks, and the total is 4. Four clicks. That is the real baseline — everything else is a guess.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why publish this
&lt;/h2&gt;

&lt;p&gt;Because the honest version is more useful than a launch post. If you are building a directory, the tracking-domain contamination and the "content in the production DB" trap will both find you, and they are easier to spot when someone else has already paid for them.&lt;/p&gt;

&lt;p&gt;If you want to look at the result: &lt;a href="https://aiverse.mindutil.com/free-ai-tools?utm_source=devto&amp;amp;utm_medium=social&amp;amp;utm_campaign=launch" rel="noopener noreferrer"&gt;AIVerse, free AI tools&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Start with the free filter — 2,198 listings, filterable by category, with the pricing field printed on every card so you can see what the data quality actually looks like. If a listing is wrong, there is a submission form and I fix it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>sideprojects</category>
      <category>webdev</category>
      <category>database</category>
    </item>
    <item>
      <title>1 mile is not 1.6 km: the exact factors, and where the shortcut breaks</title>
      <dc:creator>Mindutil</dc:creator>
      <pubDate>Sun, 25 Oct 2026 02:34:00 +0000</pubDate>
      <link>https://dev.to/li_yi_dcec96bf2647dec1b7e/1-mile-is-not-16-km-the-exact-factors-and-where-the-shortcut-breaks-1d3f</link>
      <guid>https://dev.to/li_yi_dcec96bf2647dec1b7e/1-mile-is-not-16-km-the-exact-factors-and-where-the-shortcut-breaks-1d3f</guid>
      <description>&lt;p&gt;Everyone learns the same trick: &lt;strong&gt;multiply miles by 1.6&lt;/strong&gt;. It's fast, it's close, and for a rough sense of distance it's fine. But "close" has a number, and that number is &lt;strong&gt;0.58%&lt;/strong&gt; — which turns into almost a kilometre the moment you're planning a 100-mile drive.&lt;/p&gt;

&lt;p&gt;This is a short reference for when you need the real number instead of the vibe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The exact factors
&lt;/h2&gt;

&lt;p&gt;These are not approximations. They are exact by definition, agreed internationally in 1959:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Conversion&lt;/th&gt;
&lt;th&gt;Exact factor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 mile → kilometres&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.609344&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 kilometre → miles&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.6213711922…&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 inch → centimetres&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.54&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 foot → metres&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.3048&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 pound → kilograms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.45359237&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 US gallon → litres&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.785411784&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because the mile is defined as exactly 1609.344 metres, every mile conversion is a multiplication with a finite decimal — no rounding needed at the source. Any error you see comes from the shortcut, not the unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much the shortcut costs
&lt;/h2&gt;

&lt;p&gt;Here's the same set of distances through three methods: the exact factor, the common &lt;code&gt;×1.6&lt;/code&gt;, and the slightly better &lt;code&gt;×1.61&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Miles&lt;/th&gt;
&lt;th&gt;Exact km&lt;/th&gt;
&lt;th&gt;×1.6&lt;/th&gt;
&lt;th&gt;Error (×1.6)&lt;/th&gt;
&lt;th&gt;×1.61&lt;/th&gt;
&lt;th&gt;Error (×1.61)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1.6093&lt;/td&gt;
&lt;td&gt;1.6&lt;/td&gt;
&lt;td&gt;−9 m&lt;/td&gt;
&lt;td&gt;1.61&lt;/td&gt;
&lt;td&gt;+0.7 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;8.0467&lt;/td&gt;
&lt;td&gt;8.0&lt;/td&gt;
&lt;td&gt;−47 m&lt;/td&gt;
&lt;td&gt;8.05&lt;/td&gt;
&lt;td&gt;+3 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;16.0934&lt;/td&gt;
&lt;td&gt;16.0&lt;/td&gt;
&lt;td&gt;−93 m&lt;/td&gt;
&lt;td&gt;16.1&lt;/td&gt;
&lt;td&gt;+7 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;26.2&lt;/td&gt;
&lt;td&gt;42.1648&lt;/td&gt;
&lt;td&gt;41.92&lt;/td&gt;
&lt;td&gt;−245 m&lt;/td&gt;
&lt;td&gt;42.18&lt;/td&gt;
&lt;td&gt;+17 m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;160.9344&lt;/td&gt;
&lt;td&gt;160.0&lt;/td&gt;
&lt;td&gt;−934 m&lt;/td&gt;
&lt;td&gt;161.0&lt;/td&gt;
&lt;td&gt;+66 m&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;×1.6&lt;/code&gt; always undershoots. &lt;code&gt;×1.61&lt;/code&gt; overshoots by about a tenth as much. Neither is wrong for a back-of-the-envelope answer — but if you're converting a distance you'll actually drive, run, or ship, the difference is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three places the error matters
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Running.&lt;/strong&gt; A marathon is 42.195 km, which is &lt;strong&gt;26.2188 miles&lt;/strong&gt; — not 26.2. If you convert 26.2 miles with the exact factor you get 42.1648 km, about 30 metres short of the real distance. The 26.2 figure is a rounded nickname that became the label.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Driving.&lt;/strong&gt; On a 100-mile leg the 1.6 shortcut is 934 metres off. That's not catastrophic, but it's enough to miss a turn in a city, and it silently accumulates across a multi-day trip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shipping and cooking.&lt;/strong&gt; Pounds and kilograms have the same trap with &lt;code&gt;÷2.2&lt;/code&gt;. One pound is 0.45359237 kg; dividing by 2.2 gives 0.454545 kg. On a 100 lb shipment that's roughly 95 grams of drift — usually irrelevant, occasionally not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule that fixes most of it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't round in the middle.&lt;/strong&gt; Convert with the exact factor, then round the final answer to the precision you actually need (two decimals for kilometres, whole grams for small weights). Rounding the factor first — 1.6093, 0.6214 — is fine; rounding it to 1.6 and then multiplying is where the error comes from.&lt;/p&gt;

&lt;p&gt;For kilometres to miles, the reciprocal is 0.621371. The common shortcut (×0.6) is off by 3.4%, which is about six times worse than the miles-to-km shortcut people complain about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two related units worth knowing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nautical mile&lt;/strong&gt; = exactly 1,852 metres. Not the same thing, and never needs the 1.6 shortcut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;US survey mile&lt;/strong&gt; = 1,609.3472… metres. It was retired from US federal use at the end of 2022; if you see it in an old dataset, that's why the numbers don't match.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you just want the number without doing the arithmetic, I keep a converter with a pre-computed table (0.5 to 1,000 miles, plus the race distances) at &lt;a href="https://tools.mindutil.com/en/tools/miles-to-km" rel="noopener noreferrer"&gt;tools.mindutil.com/en/tools/miles-to-km&lt;/a&gt;. It runs entirely in the browser — no upload, no account. The same table exists for &lt;a href="https://tools.mindutil.com/en/tools/cm-to-inches" rel="noopener noreferrer"&gt;cm to inches&lt;/a&gt; and &lt;a href="https://tools.mindutil.com/en/tools/kg-to-lb" rel="noopener noreferrer"&gt;kg to lb&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The point isn't that mental math is bad. It's that 1.6 is a &lt;em&gt;label&lt;/em&gt; for "roughly", and it's worth knowing exactly how roughly.&lt;/p&gt;

</description>
      <category>math</category>
      <category>productivity</category>
      <category>beginners</category>
      <category>todayilearned</category>
    </item>
    <item>
      <title>Base64 encode and decode in the browser — with proper Unicode support</title>
      <dc:creator>Mindutil</dc:creator>
      <pubDate>Thu, 22 Oct 2026 03:40:47 +0000</pubDate>
      <link>https://dev.to/li_yi_dcec96bf2647dec1b7e/base64-encode-and-decode-in-the-browser-with-proper-unicode-support-736</link>
      <guid>https://dev.to/li_yi_dcec96bf2647dec1b7e/base64-encode-and-decode-in-the-browser-with-proper-unicode-support-736</guid>
      <description>&lt;h2&gt;
  
  
  The problem with most Base64 tools
&lt;/h2&gt;

&lt;p&gt;You paste text into an online Base64 encoder, hit a button, get the result. Simple enough — until you try encoding something like &lt;code&gt;你好世界&lt;/code&gt; or &lt;code&gt;🎉&lt;/code&gt; and the output comes back as garbage, or the site silently drops characters.&lt;/p&gt;

&lt;p&gt;The root cause is almost always the same: the tool converts text to bytes using Latin-1 (a single-byte encoding) instead of UTF-8 (variable-length, covers all of Unicode). The result is a Base64 string that decodes correctly in ASCII contexts but produces mojibake the moment someone reads it back.&lt;/p&gt;

&lt;p&gt;Here is how the encoding actually works, what the pitfalls are, and how to verify that any Base64 tool — online or otherwise — handles Unicode correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encoding: text → bytes → Base64
&lt;/h2&gt;

&lt;p&gt;The pipeline has two steps. First, convert the string to a UTF-8 byte sequence. Second, encode those bytes as Base64.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encodeBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunkSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0x8000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunkSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;chunkSize&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TextEncoder&lt;/code&gt; produces a &lt;code&gt;Uint8Array&lt;/code&gt; of UTF-8 bytes — &lt;code&gt;你好&lt;/code&gt; becomes 6 bytes, &lt;code&gt;🎉&lt;/code&gt; becomes 4. The loop then feeds those bytes to &lt;code&gt;btoa()&lt;/code&gt; in chunks. The chunk size (&lt;code&gt;0x8000 = 32768&lt;/code&gt;) keeps &lt;code&gt;String.fromCharCode&lt;/code&gt; calls small enough to avoid call-stack limits on large inputs.&lt;/p&gt;

&lt;p&gt;The naive one-liner many people reach for — &lt;code&gt;btoa(unescape(encodeURIComponent(text)))&lt;/code&gt; — works but is slower and harder to reason about. &lt;code&gt;TextEncoder&lt;/code&gt; is the modern equivalent and has been available in every browser for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decoding: Base64 → bytes → text
&lt;/h2&gt;

&lt;p&gt;The reverse direction has more ways to fail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeBase64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Strip whitespace, accept URL-safe alphabet (- and _)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;compact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9+&lt;/span&gt;&lt;span class="se"&gt;/]&lt;/span&gt;&lt;span class="sr"&gt;*$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid characters in Base64 input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid Base64 length&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Malformed Base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fatal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Decoded bytes are not valid UTF-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details worth calling out:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. URL-safe Base64.&lt;/strong&gt; The standard alphabet uses &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt;, but URLs often replace those with &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt;. A decoder that does not normalise first will reject valid input or produce wrong output. The &lt;code&gt;replace&lt;/code&gt; calls above handle both alphabets transparently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Padding tolerance.&lt;/strong&gt; Base64 output is padded with &lt;code&gt;=&lt;/code&gt; to a multiple of 4 characters. Some generators omit the padding; some add extra newlines. Stripping trailing &lt;code&gt;=&lt;/code&gt; and whitespace before decoding avoids both failure modes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The &lt;code&gt;fatal: true&lt;/code&gt; flag.&lt;/strong&gt; Without it, &lt;code&gt;TextDecoder&lt;/code&gt; silently replaces invalid byte sequences with the Unicode replacement character &lt;code&gt;U+FFFD&lt;/code&gt;. With it, a single bad byte throws, which is what you want — silent corruption is worse than a loud error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three quick tests for any Base64 tool
&lt;/h2&gt;

&lt;p&gt;Before you trust an online encoder with real data:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Encode &lt;code&gt;你好&lt;/code&gt;.&lt;/strong&gt; Correct result: &lt;code&gt;5L2g5aW9&lt;/code&gt;. If the tool throws instead of giving you that, it is calling &lt;code&gt;btoa()&lt;/code&gt; on the raw string — &lt;code&gt;btoa&lt;/code&gt; rejects anything above U+00FF with &lt;code&gt;InvalidCharacterError&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode &lt;code&gt;🎉&lt;/code&gt;.&lt;/strong&gt; Correct result: &lt;code&gt;8J+OiQ==&lt;/code&gt; (note the two &lt;code&gt;=&lt;/code&gt; padding characters — a 4-byte input needs padding to reach a multiple of 4 output characters). If you get a 4-character string without padding, the tool is stripping it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decode &lt;code&gt;5L2g5aW9&lt;/code&gt; back.&lt;/strong&gt; Must produce &lt;code&gt;你好&lt;/code&gt; exactly. Round-tripping is the real test — a tool can produce plausible-looking output and still be mapping bytes wrongly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a tool fails any of these, it is not handling Unicode. Full stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped
&lt;/h2&gt;

&lt;p&gt;I put this exact pipeline behind a page with bidirectional conversion, URL-safe alphabet support, whitespace tolerance, and clear error messages when the input is not valid Base64 or does not decode to valid UTF-8: &lt;a href="https://tools.mindutil.com/en/tools/base64" rel="noopener noreferrer"&gt;ToolStack Base64 Encoder/Decoder&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It is part of a set of 69 browser-local tools — JSON formatting, image compression, colour converters, unit conversion, calculators, QR codes — in 7 languages. No sign-up, nothing uploaded. For the Base64 tool, the entire pipeline is &lt;code&gt;TextEncoder&lt;/code&gt; → &lt;code&gt;btoa&lt;/code&gt; / &lt;code&gt;atob&lt;/code&gt; → &lt;code&gt;TextDecoder&lt;/code&gt;, exactly as above.&lt;/p&gt;

&lt;p&gt;Disclosure, since I would check it on someone else's page: GA4 page-level analytics is enabled; text input and output never leave the tab, and there is no server-side storage of anything you paste.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tools.mindutil.com/" rel="noopener noreferrer"&gt;https://tools.mindutil.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>unicode</category>
    </item>
    <item>
      <title>Compress images in the browser — 20 lines, zero uploads</title>
      <dc:creator>Mindutil</dc:creator>
      <pubDate>Mon, 12 Oct 2026 08:54:39 +0000</pubDate>
      <link>https://dev.to/li_yi_dcec96bf2647dec1b7e/compress-images-in-the-browser-20-lines-zero-uploads-5d</link>
      <guid>https://dev.to/li_yi_dcec96bf2647dec1b7e/compress-images-in-the-browser-20-lines-zero-uploads-5d</guid>
      <description>&lt;h2&gt;
  
  
  The boring problem
&lt;/h2&gt;

&lt;p&gt;Almost every "compress your image" site does the same thing: your file leaves the browser, gets uploaded to a bucket, is processed by something on the other end, and comes back as a download link. Fine for a wallpaper. Less fine for a screenshot with a customer's address in it, an ID photo, or a draft design that hasn't shipped.&lt;/p&gt;

&lt;p&gt;Browsers have had a perfectly good image encoder the whole time. Here it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole thing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// applies EXIF orientation&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// JPEG has no alpha channel: transparent areas would come out black.&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;canvas.toBlob(blob, type, quality)&lt;/code&gt; is the actual encoder. The third argument only applies to lossy formats. That is the entire pipeline — decode, resample, encode — and no byte of it touches a network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four details that separate this from a toy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. EXIF orientation.&lt;/strong&gt; Phone photos carry an &lt;code&gt;Orientation&lt;/code&gt; tag. If you decode via &lt;code&gt;new Image()&lt;/code&gt; and &lt;code&gt;drawImage&lt;/code&gt;, many browsers ignore it and you get a rotated output. &lt;code&gt;createImageBitmap()&lt;/code&gt; applies the orientation when it decodes, which is why it is the entry point above instead of an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Transparency turns black.&lt;/strong&gt; A Canvas starts transparent, and JPEG has no alpha, so transparent pixels encode as black. If you are converting PNG → JPEG, paint a white rectangle first, as above. If you keep PNG, be aware that &lt;code&gt;quality&lt;/code&gt; is ignored entirely — PNG is lossless, so the "compressor" just re-encodes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Re-encoding can make files bigger.&lt;/strong&gt; This is the one nobody warns you about. Take an already-optimised PNG, run it through a browser encoder at the same dimensions, and you frequently get a &lt;em&gt;larger&lt;/em&gt; file. So compare sizes and keep the original when the encoder loses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keptOriginal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="cm"&gt;/* same dims &amp;amp; format */&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;keptOriginal&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A compressor that silently returns a bigger file is worse than no compressor, so the UI has to say "already optimised" instead of showing a fake −12%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Canvas size limits.&lt;/strong&gt; Browsers cap canvas dimensions, and past the limit you often get a silently blank result rather than an error. Around 16384 px on the longest edge is the safe cross-browser zone, so clamp first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16384&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;largest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;largest&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;largest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When this is the wrong tool
&lt;/h2&gt;

&lt;p&gt;Honest limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;toBlob('image/webp')&lt;/code&gt; support is uneven&lt;/strong&gt; (Safari added it in 16.4), and &lt;strong&gt;AVIF encoding is not available this way at all&lt;/strong&gt;. If you want the smallest file, &lt;code&gt;sharp&lt;/code&gt;/&lt;code&gt;mozjpeg&lt;/code&gt;/&lt;code&gt;svt-av1&lt;/code&gt; on a server still wins, because browser encoders are optimised for speed, not density.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Huge batches&lt;/strong&gt; belong on a server or in a worker; doing it on the main thread freezes the UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HEIC input&lt;/strong&gt; only decodes where the browser natively supports it — Safari/macOS and Windows with the HEIF extension. Chrome on Linux generally cannot. A site that claims universal HEIC conversion in the browser is shipping a WASM decoder; check the bundle size if it matters to you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I shipped
&lt;/h2&gt;

&lt;p&gt;I put this exact pipeline behind a page — quality slider, optional resize, format choice, before/after sizes, drag-and-drop, no account: &lt;a href="https://tools.mindutil.com/en/tools/image-compressor" rel="noopener noreferrer"&gt;ToolStack Image Compressor&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It is part of a set of 32 browser-local tools (JSON, Base64, image compression/resizing/cropping, colour converters, unit conversion, calculators, QR codes), with the interface in 7 languages. No sign-up, and nothing is uploaded — for the image tools that is &lt;code&gt;createImageBitmap&lt;/code&gt; + Canvas, exactly as above.&lt;/p&gt;

&lt;p&gt;Disclosure, since I would check it on someone else's page: GA4 page-level analytics is enabled; image data and text input never leave the tab, and there is no server-side storage of anything you paste or drop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tools.mindutil.com/" rel="noopener noreferrer"&gt;https://tools.mindutil.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Compress images in the browser — 20 lines, zero uploads</title>
      <dc:creator>Mindutil</dc:creator>
      <pubDate>Mon, 12 Oct 2026 07:21:08 +0000</pubDate>
      <link>https://dev.to/li_yi_dcec96bf2647dec1b7e/compress-images-in-the-browser-20-lines-zero-uploads-3dcb</link>
      <guid>https://dev.to/li_yi_dcec96bf2647dec1b7e/compress-images-in-the-browser-20-lines-zero-uploads-3dcb</guid>
      <description>&lt;h2&gt;
  
  
  The boring problem
&lt;/h2&gt;

&lt;p&gt;Almost every "compress your image" site does the same thing: your file leaves the browser, gets uploaded to a bucket, is processed by something on the other end, and comes back as a download link. Fine for a wallpaper. Less fine for a screenshot with a customer's address in it, an ID photo, or a draft design that hasn't shipped.&lt;/p&gt;

&lt;p&gt;Browsers have had a perfectly good image encoder the whole time. Here it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole thing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// applies EXIF orientation&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// JPEG has no alpha channel: transparent areas would come out black.&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;canvas.toBlob(blob, type, quality)&lt;/code&gt; is the actual encoder. The third argument only applies to lossy formats. That is the entire pipeline — decode, resample, encode — and no byte of it touches a network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four details that separate this from a toy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. EXIF orientation.&lt;/strong&gt; Phone photos carry an &lt;code&gt;Orientation&lt;/code&gt; tag. If you decode via &lt;code&gt;new Image()&lt;/code&gt; and &lt;code&gt;drawImage&lt;/code&gt;, many browsers ignore it and you get a rotated output. &lt;code&gt;createImageBitmap()&lt;/code&gt; applies the orientation when it decodes, which is why it is the entry point above instead of an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Transparency turns black.&lt;/strong&gt; A Canvas starts transparent, and JPEG has no alpha, so transparent pixels encode as black. If you are converting PNG → JPEG, paint a white rectangle first, as above. If you keep PNG, be aware that &lt;code&gt;quality&lt;/code&gt; is ignored entirely — PNG is lossless, so the "compressor" just re-encodes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Re-encoding can make files bigger.&lt;/strong&gt; This is the one nobody warns you about. Take an already-optimised PNG, run it through a browser encoder at the same dimensions, and you frequently get a &lt;em&gt;larger&lt;/em&gt; file. So compare sizes and keep the original when the encoder loses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keptOriginal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;keptOriginal&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A compressor that silently returns a bigger file is worse than no compressor, so the UI has to say "already optimised" instead of showing a fake −12%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Canvas size limits.&lt;/strong&gt; Browsers cap canvas dimensions, and past the limit you often get a silently blank result rather than an error. Around 16384 px on the longest edge is the safe cross-browser zone, so clamp first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16384&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;largest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;largest&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;largest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When this is the wrong tool
&lt;/h2&gt;

&lt;p&gt;Honest limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;toBlob('image/webp')&lt;/code&gt; support is uneven&lt;/strong&gt; (Safari added it in 16.4), and &lt;strong&gt;AVIF encoding is not available this way at all&lt;/strong&gt;. If you want the smallest file, &lt;code&gt;sharp&lt;/code&gt;/&lt;code&gt;mozjpeg&lt;/code&gt;/&lt;code&gt;svt-av1&lt;/code&gt; on a server still wins, because browser encoders are optimised for speed, not density.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Huge batches&lt;/strong&gt; belong on a server or in a worker; doing it on the main thread freezes the UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HEIC input&lt;/strong&gt; only decodes where the browser natively supports it — Safari/macOS and Windows with the HEIF extension. Chrome on Linux generally cannot. A site that claims universal HEIC conversion in the browser is shipping a WASM decoder; check the bundle size if it matters to you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I shipped
&lt;/h2&gt;

&lt;p&gt;I put this exact pipeline behind a page — quality slider, optional resize, format choice, before/after sizes, drag-and-drop, no account: &lt;a href="https://tools.mindutil.com/en/tools/image-compressor" rel="noopener noreferrer"&gt;ToolStack Image Compressor&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It is part of a set of 32 browser-local tools (JSON, Base64, image compression/resizing/cropping, colour converters, unit conversion, calculators, QR codes), with the interface in 7 languages. No sign-up, and nothing is uploaded — for the image tools that is &lt;code&gt;createImageBitmap&lt;/code&gt; + Canvas, exactly as above.&lt;/p&gt;

&lt;p&gt;Disclosure, since I would check it on someone else's page: GA4 page-level analytics is enabled; image data and text input never leave the tab, and there is no server-side storage of anything you paste or drop.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tools.mindutil.com/" rel="noopener noreferrer"&gt;https://tools.mindutil.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Format JSON without pasting it into a random website</title>
      <dc:creator>Mindutil</dc:creator>
      <pubDate>Sat, 10 Oct 2026 09:18:56 +0000</pubDate>
      <link>https://dev.to/li_yi_dcec96bf2647dec1b7e/format-json-without-pasting-it-into-a-random-website-27ja</link>
      <guid>https://dev.to/li_yi_dcec96bf2647dec1b7e/format-json-without-pasting-it-into-a-random-website-27ja</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;You hit an API, get a 4,000-character single line back, and need it readable. So you search "online json formatter", paste the response into the first site that loads, and press Format.&lt;/p&gt;

&lt;p&gt;That response probably contains access tokens, customer records, internal hostnames — data you would not email to a stranger. Yet the paste box on a no-name domain has no particular reason to be more trustworthy, and nothing on the page tells you where the bytes went.&lt;/p&gt;

&lt;p&gt;Here are three ways to get formatted JSON without it leaving your machine, plus a two-minute way to check whether any online formatter is telling the truth about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The DevTools one-liner (fastest)
&lt;/h2&gt;

&lt;p&gt;Open the console and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawString&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;null, 2&lt;/code&gt; is the pretty-print part: second argument is a replacer, third is the indent — &lt;code&gt;2&lt;/code&gt;, &lt;code&gt;4&lt;/code&gt;, or a &lt;code&gt;"\t"&lt;/code&gt; string. If the payload is an array of objects, &lt;code&gt;console.table(JSON.parse(rawString))&lt;/code&gt; is often more readable than the tree.&lt;/p&gt;

&lt;p&gt;Zero installs, zero uploads. The catch is the failure mode: if the input is invalid, &lt;code&gt;JSON.parse&lt;/code&gt; throws and the message is frequently useless — "Unexpected token } in JSON at position 2471". Which line? Which column? You get neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Command line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="nb"&gt;.&lt;/span&gt; &amp;lt; response.json     &lt;span class="c"&gt;# pretty-print&lt;/span&gt;
jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &amp;lt; response.json  &lt;span class="c"&gt;# minify to one line&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or without jq installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'const fs=require("fs");process.stdout.write(JSON.stringify(JSON.parse(fs.readFileSync(0,"utf8")),null,2))'&lt;/span&gt; &amp;lt; response.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Good for files you already have on disk. Awkward for the "select 300 lines from a log, make it readable, move on" case — and &lt;code&gt;jq&lt;/code&gt; is not on a default Windows box.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A purely client-side web page
&lt;/h2&gt;

&lt;p&gt;This is the gap I eventually filled with &lt;a href="https://tools.mindutil.com/" rel="noopener noreferrer"&gt;ToolStack&lt;/a&gt; — essentially the browser version of &lt;code&gt;jq&lt;/code&gt;, without the trust question. The &lt;a href="https://tools.mindutil.com/en/tools/json-formatter" rel="noopener noreferrer"&gt;JSON formatter page&lt;/a&gt; covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Format&lt;/strong&gt; with 2 spaces / 4 spaces / tab&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minify&lt;/strong&gt; to a single line&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate&lt;/strong&gt; with an error panel showing &lt;strong&gt;line, column and character offset&lt;/strong&gt;, not just a raw parser message&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Syntax highlighting and line numbers in both input and output&lt;/strong&gt;, so a 3,000-line response is skimmable&lt;/li&gt;
&lt;li&gt;Copy, download as &lt;code&gt;formatted.json&lt;/code&gt;, and &lt;code&gt;Ctrl/Cmd + Enter&lt;/code&gt; to format from the keyboard&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One implementation detail worth knowing, because it is the difference between "useful" and "annoying": V8 does not include a position in every &lt;code&gt;JSON.parse&lt;/code&gt; error class — &lt;code&gt;{"a":}&lt;/code&gt; yields none at all. Recovering it means parsing the text a second time with a tolerant grammar parser (the Lezer JSON parser here) and taking the offset of the first error node. That is what lets the UI point at a line instead of shrugging.&lt;/p&gt;

&lt;p&gt;No request is made either way: the input goes through &lt;code&gt;JSON.parse&lt;/code&gt; inside the page, so there is nothing on the wire to sniff.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to verify any "runs locally" claim yourself
&lt;/h2&gt;

&lt;p&gt;Three checks, about two minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DevTools → Network → filter on fetch/XHR.&lt;/strong&gt; Paste a large blob, hit Format, watch for outgoing requests. Nothing should appear except analytics beacons — and note that telemetry is a separate question from your data being uploaded, so check what those payloads actually contain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull the plug.&lt;/strong&gt; With the page loaded, tick "Offline" in the Network panel (or switch off your adapter) and run the tool again. If it still works, the computation is local.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the client.&lt;/strong&gt; View source and grep the bundle for &lt;code&gt;fetch(&lt;/code&gt;, &lt;code&gt;XMLHttpRequest&lt;/code&gt;, &lt;code&gt;FormData&lt;/code&gt;, &lt;code&gt;navigator.sendBeacon&lt;/code&gt;. A genuine client-side tool has its whole logic in the browser.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Disclosure for my own page, since the above is how I would audit it: GA4 page-level analytics is on; tool input and output are never captured; there is no account system and nothing is stored server-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to look around
&lt;/h2&gt;

&lt;p&gt;32 tools in total — JSON, Base64, image compression and format conversion (JPG/PNG/WebP/HEIC), color pickers and converters, unit conversion, calculators, QR codes — in 7 languages, no sign-up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://tools.mindutil.com/" rel="noopener noreferrer"&gt;https://tools.mindutil.com/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>json</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
