<?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: bowen Tse</title>
    <description>The latest articles on DEV Community by bowen Tse (@bowentse).</description>
    <link>https://dev.to/bowentse</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%2F4157799%2F6354d1ae-fa07-4314-bf9f-ab94d5496563.jpg</url>
      <title>DEV Community: bowen Tse</title>
      <link>https://dev.to/bowentse</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bowentse"/>
    <language>en</language>
    <item>
      <title>I Built 30 Free Tools That Run Entirely in Your Browser</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Sun, 25 Oct 2026 03:33:08 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-30-free-tools-that-run-entirely-in-your-browser-18d2</link>
      <guid>https://dev.to/bowentse/i-built-30-free-tools-that-run-entirely-in-your-browser-18d2</guid>
      <description>&lt;h1&gt;
  
  
  I Built 30 Free Tools That Run Entirely in Your Browser
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I'm the founder of ToolkitLoop, the project this story is about. Everything below is my own experience building it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The upload button that broke me
&lt;/h2&gt;

&lt;p&gt;Last year I needed to convert a JPG to WebP. Simple task. Thirty seconds, tops.&lt;/p&gt;

&lt;p&gt;I opened the first result on Google. It asked me to upload my image.&lt;/p&gt;

&lt;p&gt;I hesitated. The image had a client's mockup in it. Nothing top-secret, but not mine to scatter across someone's S3 bucket either.&lt;/p&gt;

&lt;p&gt;I opened the second result. It wanted an account before it would even show me the converter.&lt;/p&gt;

&lt;p&gt;The third one worked without signup. But the moment I dropped the file in, I watched the progress bar crawl as my image traveled to a server somewhere, got processed God-knows-where, and came back.&lt;/p&gt;

&lt;p&gt;For a format conversion. Something my laptop could do in milliseconds.&lt;/p&gt;

&lt;p&gt;That was the moment the idea lodged itself in my head and wouldn't leave: why does a trivial computation need my file at all?&lt;/p&gt;

&lt;h2&gt;
  
  
  The insight: your browser is a supercomputer
&lt;/h2&gt;

&lt;p&gt;Here's the thing most people never think about. The device you're reading this on can run circles around the servers that hosted the entire web in 2005.&lt;/p&gt;

&lt;p&gt;Modern browsers ship with staggering capability. Canvas manipulation. WebCrypto. Web Workers for background processing. The File API for reading local files without ever touching a network. WebAssembly for near-native speed.&lt;/p&gt;

&lt;p&gt;A JPG-to-WebP conversion? The browser does it natively with &lt;code&gt;createImageBitmap&lt;/code&gt; and canvas export. A JSON formatter? That's string manipulation — microseconds. A QR code generator? Pure math, rendered to canvas.&lt;/p&gt;

&lt;p&gt;None of these need a server. They never did. The server was there because it was easier for the &lt;em&gt;developer&lt;/em&gt;, not better for the &lt;em&gt;user&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Servers mean infrastructure bills. Infrastructure bills mean the tool needs to make money. Making money means ads, or paywalls, or harvesting your uploads as training data, or all three.&lt;/p&gt;

&lt;p&gt;Remove the server and the whole rotten incentive chain collapses. No uploads means nothing to leak, sell, or breach. No accounts means nothing to track. No backend means the marginal cost of one more user is effectively zero — so the tool can actually be free, forever, without a catch.&lt;/p&gt;

&lt;p&gt;That's the bet I decided to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building it: browser-first as architecture, not marketing
&lt;/h2&gt;

&lt;p&gt;"Browser-first" gets thrown around a lot. For ToolkitLoop, it's a hard architectural constraint, not a tagline.&lt;/p&gt;

&lt;p&gt;Every tool runs 100% client-side. The page loads, and from that point on, you could pull your ethernet cable and everything keeps working. Your files never leave your device. There is no API endpoint receiving your data because there is no API.&lt;/p&gt;

&lt;p&gt;This constraint shaped every technical decision.&lt;/p&gt;

&lt;p&gt;State lives in the page, not in a database. Heavy work goes to Web Workers so the UI never stutters. Anything that looks like it needs a server — I either found a client-side way or cut the feature.&lt;/p&gt;

&lt;p&gt;The upside goes beyond privacy. These tools are &lt;em&gt;fast&lt;/em&gt;. No round trips. No spinners waiting on a server. No rate limits because there's no shared resource to protect. And they're resilient — no backend means no backend outages.&lt;/p&gt;

&lt;p&gt;The downside is real too, and I'll be honest about it. Some things genuinely need servers: anything collaborative, anything that needs to persist across devices, anything with serious compute. I don't pretend browser-first solves everything. It solves the large, neglected category of &lt;em&gt;personal utility tasks&lt;/em&gt; — the format conversions, the calculators, the generators, the validators — where a server was always overkill.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete example: the image converter pipeline
&lt;/h2&gt;

&lt;p&gt;Since this is HackerNoon, let me show rather than tell. Here's roughly what happens when you drop a JPG into the converter:&lt;/p&gt;

&lt;p&gt;The File API reads the bytes locally. &lt;code&gt;createImageBitmap&lt;/code&gt; decodes it — using the browser's native, hardware-accelerated codecs, the same ones that render every image on the web. The bitmap goes to an offscreen canvas. Then &lt;code&gt;canvas.convertToBlob({ type: 'image/webp', quality: 0.85 })&lt;/code&gt; re-encodes it.&lt;/p&gt;

&lt;p&gt;That's the whole pipeline. Four steps, zero network calls, and it runs in a Web Worker so the tab stays responsive on a 50-megapixel panorama.&lt;/p&gt;

&lt;p&gt;The QR code scanner is my favorite example of the philosophy paying off. It uses &lt;code&gt;getUserMedia&lt;/code&gt; to open your camera, grabs frames to a canvas, and runs the detection algorithm right there in the page. Your camera feed never leaves the device — which, when you think about it, is the &lt;em&gt;only&lt;/em&gt; sane way to build a QR scanner. The alternative is streaming your camera to a stranger's server. People do this every day without blinking.&lt;/p&gt;

&lt;p&gt;For developers reading this: the constraint forces a useful discipline. Every feature request gets filtered through one question — can this run on the client? If yes, it ships without any infrastructure work. If no, it gets cut or redesigned. There's no "we'll add a microservice for that" escape hatch, which means the architecture can't drift into complexity. The codebase stays boring in the best possible way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Thirty tools, one philosophy
&lt;/h2&gt;

&lt;p&gt;I didn't start with thirty. I started with one — a character counter, built for a friend who kept blowing past comment length limits on forums.&lt;/p&gt;

&lt;p&gt;Then a QR code scanner. Then a JSON formatter. Each one took the same shape: open the page, do the thing, close the tab. No friction.&lt;/p&gt;

&lt;p&gt;Somewhere around tool fifteen, I realized the collection itself was the product. Nobody wakes up wanting "a tools website." They wake up wanting to convert a timestamp, or validate a regex, or split a bill. The job-to-be-done is atomic. So the site is organized around that: thirty independent tools, each one a complete answer to a specific "I need to…" moment.&lt;/p&gt;

&lt;p&gt;The current lineup spans the boring-but-essential: percentage calculators, unit converters, currency converters, a tip calculator that splits bills penny-perfect. And the developer-flavored: diff checker, regex tester, JSON formatter, barcode generator, favicon generator, case converter.&lt;/p&gt;

&lt;p&gt;Boring is a compliment here. These are the tasks you do a hundred times a year and never think about — until the tool you trusted asks for your email address.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distribution problem (being honest)
&lt;/h2&gt;

&lt;p&gt;Here's where the build-in-public honesty kicks in: building the tools was the easy part. Getting anyone to find them is the actual hard problem.&lt;/p&gt;

&lt;p&gt;I'm a solo founder. No funding, no marketing team, no ad budget. So I'm running the playbook available to me: make each tool page genuinely excellent for its specific query, and let search do the work.&lt;/p&gt;

&lt;p&gt;Part of that is a deliberate experiment I ran recently: 250 programmatic long-tail pages — currency pairs, unit conversions — each one a real page with real computed content, not thin doorway spam. It's too early to call it a success or failure. I'm watching the data for 4–6 weeks before deciding whether to expand it to other languages.&lt;/p&gt;

&lt;p&gt;That's the unglamorous truth of indie hacking. You ship, you measure, you decide. No growth hacks. Just compounding small bets.&lt;/p&gt;

&lt;p&gt;I'm also writing about the process — tutorial-style articles on dev.to, Hashnode, and now here on HackerNoon. Not "10x your productivity" listicles. Actual how-tos with code, aimed at people who build things. If that sounds like you, we're probably going to get along.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why free is sustainable (the math)
&lt;/h2&gt;

&lt;p&gt;People always ask where the catch is. Fair question — "free" on the internet usually means you're the product.&lt;/p&gt;

&lt;p&gt;Here's the math. A traditional tool site pays for servers, bandwidth, storage, and the engineering to keep uploads secure. Every user costs money. So every user has to &lt;em&gt;make&lt;/em&gt; money, somehow.&lt;/p&gt;

&lt;p&gt;My cost structure is a static site on a CDN. Whether ten people or ten million people use the tools, my marginal cost per user is essentially zero — the compute happens on &lt;em&gt;their&lt;/em&gt; devices, the bandwidth is static assets cached at the edge.&lt;/p&gt;

&lt;p&gt;That's what makes "free with no catch" an honest statement rather than a growth-hacking slogan. I'm not subsidizing your usage while I figure out monetization. The architecture genuinely doesn't need your money to serve you.&lt;/p&gt;

&lt;p&gt;Could I still add a paid tier someday? Sure — for power features that also run locally, or for teams. But the free tools don't need to convert you into a paying customer to justify their existence. That's a structural difference, not a pricing tactic, and it's the reason I can promise the free tier isn't going anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The roadmap is driven by a simple filter: is this a task where a server is overkill?&lt;/p&gt;

&lt;p&gt;A Core Web Vitals checker is in the works — it queries Google's public CrUX API directly from your browser, no backend proxy, with historical trend data the existing checkers don't show. Same philosophy, new territory.&lt;/p&gt;

&lt;p&gt;Longer term, I want to prove that the browser-first model can sustain a real business without compromising. Maybe that's a pro tier for power features that still run locally. Maybe it's something I haven't thought of yet. The constraint — never touch user data — is non-negotiable. The business model has to fit inside it, not the other way around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, break it, tell me
&lt;/h2&gt;

&lt;p&gt;Everything is at toolkitloop.com. No signup. No tracking. Open a tool, use it, close the tab.&lt;/p&gt;

&lt;p&gt;If something's broken or missing, I genuinely want to hear about it — solo founders don't get bug reports unless they ask. You can find me here on HackerNoon or on X.&lt;/p&gt;

&lt;p&gt;And if you've ever stared at an upload button and thought "why does this need my file" — now you know you're not alone. I built thirty tools out of that exact frustration.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>privacy</category>
      <category>indiehacking</category>
    </item>
    <item>
      <title>I Built a Client-Side JSON Formatter: 5 Lessons for Devs</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Sun, 25 Oct 2026 03:00:29 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-client-side-json-formatter-5-lessons-for-devs-2i34</link>
      <guid>https://dev.to/bowentse/i-built-a-client-side-json-formatter-5-lessons-for-devs-2i34</guid>
      <description>&lt;p&gt;Every developer has pasted a minified API response into a formatter and stared at one long line, wondering where the nesting went wrong. I built a client-side JSON formatter to solve that exact moment, and the work taught me more about JSON than years of casually using it. Disclosure first: I built this tool — it is part of ToolkitLoop, my collection of free browser tools — so this post is both a tutorial and a build log.&lt;br&gt;
Here are the five lessons, each with code you can run today.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. JSON is stricter than JavaScript, and that is the point
&lt;/h2&gt;

&lt;p&gt;JavaScript lets you write {name: 'Alex', active: True,} and nobody complains until you try to parse it as JSON. JSON does not allow unquoted keys, single-quoted strings, Python-style booleans, trailing commas, or comments. Each of those is a feature of some other language, not a bug in your data — but JSON rejects all of them.&lt;br&gt;
The core formatting operation is just two lines:&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;data&lt;/span&gt; &lt;span class="o"&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;rawText&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;pretty&lt;/span&gt; &lt;span class="o"&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;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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;JSON.parse enforces the strict rules. JSON.stringify with an indent of 2 produces the readable output. Everything else in a formatter — the tree view, the search box, the repair button — is help around those two lines. When you understand that, debugging gets simpler: if JSON.parse throws, the document is invalid, full stop. No formatter setting will change that fact; only fixing the text will.&lt;br&gt;
A practical habit follows from this: keep your parse and your repair separate. Parse first to learn whether the document is valid. Repair only when you know exactly which predictable mistake you are fixing. Mixing the two steps hides the error you most need to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Safe repair means a small, honest list
&lt;/h2&gt;

&lt;p&gt;The most tempting feature in a formatter is an aggressive "fix everything" button. I resisted it, and here is why: a repair that guesses can silently change meaning. If a tool invents a missing field, reorders data, or converts a type to make parsing succeed, you may ship a document that parses cleanly and behaves wrongly.&lt;br&gt;
The repair list I kept is deliberately small and predictable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;trailing commas removed&lt;/li&gt;
&lt;li&gt;single-quoted strings converted to double quotes&lt;/li&gt;
&lt;li&gt;simple unquoted keys quoted&lt;/li&gt;
&lt;li&gt;True, False, and None converted to true, false, and null&lt;/li&gt;
&lt;li&gt;comments stripped&lt;/li&gt;
&lt;li&gt;common AI code fences stripped
That is it. Each item is a copy-and-paste mistake with one obvious correction. Anything outside that list stays an error, and the user sees the error instead of a silent guess. The tool page says this plainly: review repaired output before using it in production. That sentence is not legal padding. It is the contract that makes automatic repair trustworthy.
Try it with the classic broken example:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Input (invalid JSON)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;broken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`{
  name: 'Alex',
  active: True,
}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// After limited repair, this parses:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`{
  "name": "Alex",
  "active": true
}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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;fixed&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// { name: 'Alex', active: true }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what did not change: the values, the field names, and the order. A good repair preserves all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Readability is a debugging tool, not cosmetics
&lt;/h2&gt;

&lt;p&gt;Formatting is often treated as tidying up, but for JSON it is genuinely a debugging step. A minified response hides nesting errors. An indented response shows them. When every field sits on its own line, a field under the wrong parent jumps out. When a tree view lets you collapse a branch, you can check one subtree without losing your place in a thousand-line document.&lt;br&gt;
Two reading aids earn their place in any formatter. First, a tree view: it turns a flat wall of text into collapsible structure, which matters most when a response contains nested arrays of objects. Second, a key search: it answers "where is the error code in this response?" without manual scanning. Neither changes the data. Both change how fast you find a problem.&lt;br&gt;
Indentation choice matters less than consistency. Two spaces is the common default, but matching your project's style matters more than the number itself. A formatted document pasted back with the wrong indentation creates a noisy diff that hides the real change. Format with your project's indent, then copy.&lt;br&gt;
You can try the workflow here: JSON Formatter (&lt;a href="https://toolkitloop.com/json-formatter" rel="noopener noreferrer"&gt;https://toolkitloop.com/json-formatter&lt;/a&gt;). Paste a response, format it, switch to the tree view, and search for one key. The whole loop takes under a minute, and it is the loop I use when an API response does not look right.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Never upload data you would not paste into a chat
&lt;/h2&gt;

&lt;p&gt;This is the lesson that changed how I think about tool architecture. A server-side formatter receives your text, processes it, and returns a result. For public sample data, that is harmless. For a production API response, a config file, or an AI answer containing project details, that round trip creates a copy of your data somewhere else — in transit, in a log, or in a processor you do not control.&lt;br&gt;
A client-side formatter avoids that round trip entirely. The tool page puts it simply: everything stays in this browser, and the text is not uploaded to ToolkitLoop. That design choice removes a whole class of exposure, and it costs nothing in features for formatting, validation, repair, tree viewing, or search. All of those run in JavaScript on your device.&lt;br&gt;
That does not make any formatter a vault. My rule, and the rule I recommend, is the same for every online tool: redact real secrets before you paste. Replace an API key with REDACTED, format the document, check the structure, and put the real value back only in your own secure system. Local processing plus redaction is a much stronger combination than either habit alone.&lt;br&gt;
If you take one habit from this post, take that one. The next time you are about to paste a production response into a random formatter, pause and ask: does this tool need to receive my data at all? For formatting, the answer is no.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Syntax is not schema, and confusing them wastes hours
&lt;/h2&gt;

&lt;p&gt;A document can be perfectly valid JSON and still be rejected by your API. That is not a contradiction. JSON syntax answers one question: does this text follow JSON's punctuation and value rules? A JSON Schema answers a different question: does this data match the expected contract — required fields, permitted types, allowed values, and formats?&lt;br&gt;
The formatter validates syntax and helps you inspect shape. It does not claim to validate a JSON Schema, and that distinction is stated on the tool page for a reason. When a clean document is rejected, the debugging order should be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Format and confirm the syntax is valid.&lt;/li&gt;
&lt;li&gt;Check the schema: is a required field missing?&lt;/li&gt;
&lt;li&gt;Check types: is a number where a string is expected, or vice versa?&lt;/li&gt;
&lt;li&gt;Check values: is a status outside the allowed list?
Skipping step 2 and re-formatting the same document five times is a familiar waste of time. I have watched developers do it, and I have done it myself. Naming the two checks separately — syntax first, schema second — ends that loop.
The companion tool makes the split explicit: the formatter makes valid JSON readable and repairs a few common mistakes, while the JSON Validator focuses on finding and explaining syntax errors. Use validation when you do not know why a document fails. Use formatting when you know it is valid and just cannot read it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;p&gt;The full workflow is short enough to remember:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Redact secrets, then paste.&lt;/li&gt;
&lt;li&gt;Parse to learn whether the document is valid.&lt;/li&gt;
&lt;li&gt;Repair only the small predictable list, then review the output.&lt;/li&gt;
&lt;li&gt;Format with your project's indentation.&lt;/li&gt;
&lt;li&gt;Read the result once, checking parents, closures, and types.&lt;/li&gt;
&lt;li&gt;If a clean document is still rejected, check the schema, not the syntax.
That is the whole tutorial, and it is also the design of the tool: format, validate, and fix JSON in your browser (&lt;a href="https://toolkitloop.com/json-formatter" rel="noopener noreferrer"&gt;https://toolkitloop.com/json-formatter&lt;/a&gt;). Free, no signup, and your text never leaves your device.
Disclosure again, because it matters: I built this tool, so I am biased toward client-side design. Try the workflow on your own ugliest API response and judge it by the result, not by this post.
What is your rule for pasting production data into online tools — redact first, use local tools only, or something else? I am curious where other developers draw the line.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Zero-Backend M3U8 Player: 5 HLS Lessons for Devs</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Fri, 23 Oct 2026 03:26:09 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-zero-backend-m3u8-player-5-hls-lessons-for-devs-1ifc</link>
      <guid>https://dev.to/bowentse/i-built-a-zero-backend-m3u8-player-5-hls-lessons-for-devs-1ifc</guid>
      <description>&lt;p&gt;Paste a &lt;code&gt;.m3u8&lt;/code&gt; URL into Chrome's address bar and the browser just downloads a text file. No error, no player — a download. That tiny moment of confusion is where most developers first meet HLS, and it usually leads to the same question: why does the video format that powers most of the internet's live streaming feel invisible to my browser?&lt;/p&gt;

&lt;p&gt;I got annoyed enough by this to build a zero-backend M3U8 player that runs entirely in the browser, with a manifest inspector and a diagnostics panel so you can see &lt;em&gt;why&lt;/em&gt; a stream fails instead of staring at a black box. Full disclosure: &lt;a href="https://toolkitloop.com/m3u8-player" rel="noopener noreferrer"&gt;it's my tool&lt;/a&gt;, free, no signup, and every URL you paste stays in your tab.&lt;/p&gt;

&lt;p&gt;Here are the five things I learned that I wish someone had told me before I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. M3U8 is not a video format — it's a shopping list
&lt;/h2&gt;

&lt;p&gt;The single most important mental model: a &lt;code&gt;.m3u8&lt;/code&gt; file is a playlist, not media. It's UTF-8 text (that's the "8" in M3U8) listing segment URLs plus metadata. A master playlist looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
http://example.com/low.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1280x720
http://example.com/hi.m3u8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each media playlist then lists 6–10 second segments. The player downloads segments one at a time and stitches them together. Adaptive streaming — the reason your livestream doesn't freeze when your Wi-Fi dips — is just the player choosing which playlist's segments to fetch based on measured throughput.&lt;/p&gt;

&lt;p&gt;Once you see it this way, "playing an HLS stream" decomposes into: fetch playlist → parse → fetch segments → remux → decode. Every HLS player, from Safari to VLC to a web page, is doing exactly this.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Your browser already knows how to play the video — it just can't read the menu
&lt;/h2&gt;

&lt;p&gt;Here's the surprise: Chrome, Edge, and Firefox are perfectly capable of decoding the &lt;em&gt;segments&lt;/em&gt;. They contain H.264/AAC in an MPEG-TS container, and browsers can demux and decode that just fine through the Media Source Extensions (MSE) API.&lt;/p&gt;

&lt;p&gt;What browsers (except Safari) lack is the &lt;em&gt;playlist layer&lt;/em&gt;: nobody wrote the code that parses &lt;code&gt;#EXT-X-STREAM-INF&lt;/code&gt;, resolves relative segment URLs, and manages the quality-switching state machine. That's what hls.js does in JavaScript — about 2,000 lines of library code that fill the gap Apple left.&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Hls&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hls.js&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;video&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;video&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;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isSupported&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;hls&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;Hls&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/stream.m3u8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Hls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MANIFEST_PARSED&lt;/span&gt;&lt;span class="p"&gt;,&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;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;play&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canPlayType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/vnd.apple.mpegurl&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="c1"&gt;// Safari: native HLS, no library needed&lt;/span&gt;
  &lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com/stream.m3u8&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That ~12 lines is the core of nearly every browser-based HLS player in existence. The rest is UI, error handling, and diagnostics.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. CORS is the #1 reason "it works in VLC but not in my browser"
&lt;/h2&gt;

&lt;p&gt;This is the trap nobody warns you about. VLC is a desktop app — it can fetch any URL on the internet. A web page cannot: the browser's same-origin policy blocks cross-origin fetches unless the server sends back an &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;HLS segments live on &lt;em&gt;someone else's&lt;/em&gt; server. If that server doesn't allow cross-origin requests, hls.js will fail with a network error and there's nothing client-side code can do about it. The same link plays fine in VLC.&lt;/p&gt;

&lt;p&gt;This is also why you should never judge a stream's health from a single environment. My player surfaces this distinction in its diagnostics: a failed fetch with no CORS headers gets flagged as a CORS restriction, not a "broken stream." If you're shipping streams, the fix is on the server side — send the header and your streams suddenly work in every web player.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A manifest inspector is worth more than a play button
&lt;/h2&gt;

&lt;p&gt;The second most common failure mode after CORS is the dead URL. HLS links with tokens expire — sometimes within hours. Geo blocking returns 403s. DRM-protected playlists need license handshakes a generic player will never perform.&lt;/p&gt;

&lt;p&gt;A player that only has a play button turns all of these into the same symptom: nothing happens. That's why I put a manifest inspector front and center in the tool: it shows whether the playlist itself loads, what variants it advertises, and what the HTTP response actually was. A 403 with a body mentioning region restrictions is a different problem than a 404 on the playlist or a playlist whose segments 404 one by one.&lt;/p&gt;

&lt;p&gt;If you're debugging streams for a living — testing that your CDN config, token auth, or multi-bitrate ladder is correct — this kind of visibility is the difference between "it doesn't work" and "the 1080p variant's segments return 403 from edge node X." The player never bypasses access control; it just reports what the network actually said.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Zero backend is a feature, not just a cost saving
&lt;/h2&gt;

&lt;p&gt;Every byte of a video stream going through my server would be a bandwidth bill and a privacy liability. The whole point of the architecture: the browser fetches the playlist and segments directly from the source. My tool never proxies, downloads, or records anything — the URL you paste, any local &lt;code&gt;.m3u8&lt;/code&gt; file you open, and the diagnostics all stay in your tab.&lt;/p&gt;

&lt;p&gt;This has a real side benefit for users who test sensitive streams: you can paste an internal staging URL without wondering whether some server is logging it. The page says it plainly — "Your URL, local file and diagnostics stay in this browser" — and it's architecturally true, not just a policy promise.&lt;/p&gt;

&lt;p&gt;One genuine edge case this design forces you to handle: local &lt;code&gt;.m3u8&lt;/code&gt; files with relative segment paths. Browsers won't let a page guess where &lt;code&gt;segment-1.ts&lt;/code&gt; lives, so the player asks for an explicit HTTPS base URL. It's a small UX wrinkle imposed by the platform's security model, and it's the honest way to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;If you're building anything with video on the web, remember the decomposition: HLS is playlists + segments + MSE. The playlist layer is the only part your browser is missing, and hls.js fills it in about 12 lines of code. When streams fail, check CORS first, tokens second, and always read the actual HTTP response before blaming the player.&lt;/p&gt;

&lt;p&gt;And if you just want to poke at a stream without writing code, the player I built is there — paste a URL, watch the manifest inspector tell you what's really going on: &lt;a href="https://toolkitloop.com/m3u8-player" rel="noopener noreferrer"&gt;toolkitloop.com/m3u8-player&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What's the weirdest stream-debugging session you've had? I've seen geo-blocked test streams that "worked on my machine" because the dev was on a VPN and forgot. Tell me yours in the comments.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Compress Any Image to Exactly 100KB: A Binary-Search Trick</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Wed, 21 Oct 2026 03:04:48 +0000</pubDate>
      <link>https://dev.to/bowentse/compress-any-image-to-exactly-100kb-a-binary-search-trick-4kh4</link>
      <guid>https://dev.to/bowentse/compress-any-image-to-exactly-100kb-a-binary-search-trick-4kh4</guid>
      <description>&lt;p&gt;Every web developer has lived this exact loop:&lt;/p&gt;

&lt;p&gt;A form says "upload must be under 100KB". You export your image at quality 80. It's 340KB. You drop to 60. It's 61KB — nice. Or it's 134KB — not nice. You try 55. It's 98KB. Close enough. Ship it.&lt;/p&gt;

&lt;p&gt;Ten minutes of blind guessing to satisfy one number. And the number you were actually asked about — the file size — was never under your control. You were tuning a &lt;em&gt;proxy&lt;/em&gt; (the quality slider) and hoping the size landed somewhere acceptable.&lt;/p&gt;

&lt;p&gt;There's a better primitive, and it works every single time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the quality slider is the wrong primitive
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable truth about the quality slider: the same quality value produces wildly different file sizes on different images. A flat illustration at quality 80 might be 40KB. A noisy smartphone photo at quality 80 might be 900KB. A gradient-heavy hero image, something else entirely.&lt;/p&gt;

&lt;p&gt;The quality number controls &lt;em&gt;how aggressively the encoder throws away detail&lt;/em&gt;, not &lt;em&gt;how many bytes come out&lt;/em&gt;. The mapping from quality to bytes depends entirely on the content of the image — its entropy, its noise, its colors. It's a function you can't evaluate without running the encoder.&lt;/p&gt;

&lt;p&gt;So the classic workflow — pick a quality, check the size, adjust, repeat — is just manual search. You are acting as a human binary-search over the quality axis, one export at a time. That's not a skill issue; it's a bad primitive. The tool gave you the wrong input.&lt;/p&gt;

&lt;p&gt;The fix is to invert the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Think target-first
&lt;/h2&gt;

&lt;p&gt;Instead of asking "what quality should I use?", ask: "what is my byte budget, and what's the &lt;em&gt;highest&lt;/em&gt; quality that fits inside it?"&lt;/p&gt;

&lt;p&gt;That's it. The input is a target — 100KB — and the output is the best-looking image that fits. This is exactly what "target-first compression" means, and it's straightforward to implement because the relationship between quality and output size is &lt;em&gt;monotonic&lt;/em&gt;: lower quality never produces a bigger file (for lossy encoders). Monotonic function? Then you can binary-search it.&lt;/p&gt;

&lt;p&gt;The algorithm:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Draw the image to a canvas, capping the long edge at a sane size (say, 1920px — there's no point compressing a 6000px photo to 100KB, it will look terrible).&lt;/li&gt;
&lt;li&gt;Binary-search the quality axis over ~8 iterations of &lt;code&gt;canvas.toBlob&lt;/code&gt;, keeping the best blob that fits the budget.&lt;/li&gt;
&lt;li&gt;Download the winner.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Eight iterations is a good number: &lt;code&gt;2^8 = 256&lt;/code&gt; quality steps, finer than any slider you've ever dragged, and it runs in milliseconds because each iteration is a single encode.&lt;/p&gt;

&lt;p&gt;Why does binary search work here? Because the encoder is &lt;em&gt;monotonic&lt;/em&gt; in quality: raising quality never shrinks the output. That one property turns a guessing game into a solved problem. (A purist will note that monotonicity can wobble at the extreme low end of some encoders — in practice, the "bank the best fit" pattern absorbs that: a wobble might cost you a slightly suboptimal answer, but never an invalid one. You'll always ship a file that fits.)&lt;/p&gt;

&lt;p&gt;Here's the whole thing, drop-in ready:&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="c1"&gt;// Binary-search the highest quality whose output fits `targetBytes`.&lt;/span&gt;
&lt;span class="c1"&gt;// canvas: HTMLCanvasElement or OffscreenCanvas, mime: "image/jpeg" or "image/webp".&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compressToTarget&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;mime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;targetBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iterations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;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;blobOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="nx"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&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;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&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;best&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;blobOf&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="c1"&gt;// lowest quality always fits — worst case fallback&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;iterations&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="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;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&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;blob&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;blobOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mid&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="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// encoder refused this setting&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;blob&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;lt;=&lt;/span&gt; &lt;span class="nx"&gt;targetBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// fits — bank it, aim higher&lt;/span&gt;
      &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// too big — go lower&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="nx"&gt;best&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Usage:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&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;compressToTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;myCanvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Final size: &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;blob&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;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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="s2"&gt; KB`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes for the copy-pasters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;canvas.toBlob&lt;/code&gt; takes quality as a float from &lt;code&gt;0&lt;/code&gt; to &lt;code&gt;1&lt;/code&gt; — not 0–100.&lt;/li&gt;
&lt;li&gt;This trick applies to lossy formats (JPEG, WebP). PNG is lossless: the quality parameter is ignored, so the search collapses immediately and you get one answer.&lt;/li&gt;
&lt;li&gt;If even quality &lt;code&gt;0&lt;/code&gt; exceeds your budget, your image is too big dimensionally — halve the canvas resolution and search again. Bytes follow pixels roughly linearly, so one resize usually does it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;best&lt;/code&gt; always holds a valid answer: you'll never get &lt;code&gt;undefined&lt;/code&gt; back, even if nothing at reasonable quality fits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the entire technique. No libraries, no server, no guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't want to write code? I built this for you
&lt;/h2&gt;

&lt;p&gt;Full disclosure: &lt;strong&gt;I built this free tool&lt;/strong&gt; — &lt;a href="https://toolkitloop.com/compress-image-to-100kb" rel="noopener noreferrer"&gt;Compress Image to 100KB&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;It does exactly what the code above does, but wrapped in a UI: type in your target size, drop in (or paste from clipboard) a JPG, PNG, or WebP, and the browser searches for the highest quality that fits your budget. The result card always shows the actual output size, so there's no guessing loop. Everything happens locally in your browser — your images are never uploaded anywhere — and it's free with no signup.&lt;/p&gt;

&lt;p&gt;What I like most about having it as a tool rather than a snippet: it removes the "is this good enough?" hesitation. When the byte budget is enforced automatically, you stop compressing defensively — you don't nuke an image to quality 55 "just to be safe" when quality 72 would have fit fine.&lt;/p&gt;

&lt;p&gt;It's the tool I wish every upload form had next to its "max 100KB" requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real question
&lt;/h2&gt;

&lt;p&gt;The technique transfers everywhere: thumbnails for a listing page, avatars with a strict size cap, email attachments that must stay under some corporate limit, or just keeping a portfolio page fast. Once you think in byte budgets instead of quality sliders, you stop exporting-and-checking entirely.&lt;/p&gt;

&lt;p&gt;So here's my question for you: &lt;strong&gt;what byte target do you actually ship images at?&lt;/strong&gt; Is 100KB still the magic number because of form limits, or have you moved to something else — 200KB? 500KB? And do you compress at upload time, at build time, or with a CDN? Drop your number and your setup in the comments — I'm curious how the real world differs from my 100KB tunnel vision.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built a Client-Side Favicon Generator (60-Second Tutorial)</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Mon, 19 Oct 2026 03:12:37 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-client-side-favicon-generator-60-second-tutorial-2aho</link>
      <guid>https://dev.to/bowentse/i-built-a-client-side-favicon-generator-60-second-tutorial-2aho</guid>
      <description>&lt;p&gt;Your logo looks great at 1200px. At 16x16, it's a smudge.&lt;/p&gt;

&lt;p&gt;That's the entire favicon problem in one sentence. Every site needs that tiny tab icon, and the standard workflow for making one is embarrassingly bad: upload your logo to some generator, create an account, wait in a queue, download a watermarked PNG, realize it's a single 32px file with no ICO, no Apple touch icon, no manifest — and start over.&lt;/p&gt;

&lt;p&gt;I got tired of that loop, so I built a favicon generator that runs 100% in the browser: type 1–3 initials or pick an emoji, set colors, preview at real tab sizes, and download the full pack (ICO, PNGs, Apple touch icons, PWA manifest). No upload, no signup, no watermark. This post is the 60-second tutorial plus the one canvas technique that makes it all work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 60-second workflow
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Type initials or pick an emoji.&lt;/strong&gt; Two bold letters ("TK") survive 16px far better than a full wordmark. An emoji works too — it gets rendered into icons at every size, 512px down to 16px.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Set background and foreground colors.&lt;/strong&gt; Contrast is everything at small sizes. Pick a shape (rounded square is the safe default) and keep the default 18% padding so the glyph doesn't touch the icon edges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Check the tiny preview.&lt;/strong&gt; This is the step most generators skip: look at your design at 64, 48, 32, 24, and 16px side by side. If the 16px version fails, simplify — thicker strokes, fewer characters, more contrast. A favicon that only looks good at 64px fails at its one job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Download the pack.&lt;/strong&gt; One click gives you the multi-size ICO, PNGs, Apple touch icons, &lt;code&gt;site.webmanifest&lt;/code&gt;, the HTML snippet, and install notes for plain HTML, Next.js, and React/Vite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Paste three lines into your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"icon"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/favicon.ico"&lt;/span&gt; &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"any"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"apple-touch-icon"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/apple-touch-icon.png"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"manifest"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/site.webmanifest"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then hard-refresh — browsers cache favicons aggressively, and a stale cache is the #1 reason "my new favicon isn't showing."&lt;/p&gt;

&lt;h2&gt;
  
  
  The canvas technique: legible glyphs at every size
&lt;/h2&gt;

&lt;p&gt;The core trick is simple: render once at a large master size, then downscale. Text drawn directly at 16px is at the mercy of the browser's font rasterizer; text drawn at 512px and scaled down keeps clean edges. Here's the essence:&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;renderFavicon&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="nx"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;padding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.18&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;S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// master size&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;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;S&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;// Background: rounded square with padding&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;padding&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="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bg&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;beginPath&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;roundRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.22&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;fill&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Foreground: centered initials, sized to the padded box&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&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="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fg&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="nx"&gt;textAlign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&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="nx"&gt;textBaseline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;middle&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="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`700 &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.52&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px system-ui, sans-serif`&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;fillText&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="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.04&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;canvas&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Downscale the master to every target size&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;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;512&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;c&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;c&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;c&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;size&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="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="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;master&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;size&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;png&lt;/span&gt; &lt;span class="o"&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;toDataURL&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/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// one per size&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 stealing for your own projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;roundRect&lt;/code&gt; + padding&lt;/strong&gt; keeps the glyph optically centered and clear of the edges where launchers clip. The 18% default is a good starting point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;+ box * 0.04&lt;/code&gt; vertical nudge&lt;/strong&gt; compensates for font metrics — most fonts render slightly high when centered by &lt;code&gt;middle&lt;/code&gt; baseline. Without it, initials look like they're floating.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One master, many downscales&lt;/strong&gt; beats re-rendering text per size. &lt;code&gt;drawImage&lt;/code&gt; downscaling from 512px produces cleaner 16px output than drawing text at 16px directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;(Emoji favicons use the same pipeline — draw the emoji glyph with &lt;code&gt;fillText&lt;/code&gt; at master size and downscale. Canvas handles the rasterization; you handle the sizes.)&lt;/p&gt;

&lt;h2&gt;
  
  
  ICO vs PNG vs SVG, in 30 seconds
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ICO&lt;/strong&gt; is the broadly compatible workhorse — one file bundles multiple sizes, every browser understands it. Your baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG&lt;/strong&gt; serves specific sizes: Apple touch icon (180x180), PWA manifest (192/512). Crisp, universal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SVG&lt;/strong&gt; only when your source is a true, safe vector. Wrapping a raster logo in SVG gains nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full checklist: 16x16 + 32x32 (tabs, inside the ICO), 180x180 (iOS), 192 + 512 (PWA manifest).&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gotchas before you ship
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Dark mode tabs.&lt;/strong&gt; A dark mark vanishes on dark browser chrome. Preview on both themes before exporting — a light rounded-square background behind a dark glyph survives either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google controls final rendering.&lt;/strong&gt; Google shows favicons in mobile search results, but on its own schedule. Keep the icon square, stable, and crawlable; updates take time to propagate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Disclosure, plainly stated: I built this tool — &lt;a href="https://toolkitloop.com/favicon-generator" rel="noopener noreferrer"&gt;ToolkitLoop's free favicon generator&lt;/a&gt; — because I was tired of the upload-an-account-watermark loop. Everything renders locally in your browser; nothing you type ever leaves the tab.&lt;/p&gt;

&lt;p&gt;Sixty seconds, five steps, one download. And now a question for the comments: &lt;strong&gt;what's the worst favicon crime you've seen in a browser tab?&lt;/strong&gt; A stretched logo? A white square on a dark tab? A 404 where the icon should be? I'm collecting specimens for the next iteration — show me your smudges. 👇&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Zero-Backend Markdown Previewer in 40 Lines of JS</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Sat, 17 Oct 2026 03:05:44 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-zero-backend-markdown-previewer-in-40-lines-of-js-4l0e</link>
      <guid>https://dev.to/bowentse/i-built-a-zero-backend-markdown-previewer-in-40-lines-of-js-4l0e</guid>
      <description>&lt;p&gt;Every Markdown previewer I tried wanted something from me first. An upload. A signup. A desktop app install for what is, at its core, a text transformation.&lt;/p&gt;

&lt;p&gt;So I built one that asks for nothing: paste Markdown, get a preview, upload zero bytes. It's one of the 30 free browser tools in my ToolkitLoop collection — full disclosure, it's my own project.&lt;/p&gt;

&lt;p&gt;The interesting part wasn't the UI. It was the three problems hiding inside "just render some Markdown."&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: GFM tables will humble you
&lt;/h2&gt;

&lt;p&gt;CommonMark is the easy part — headings, emphasis, lists. The pain starts with GitHub Flavored Markdown, specifically tables and task lists.&lt;/p&gt;

&lt;p&gt;A table looks simple until you hit the edge cases: rows with mismatched cell counts, escaped pipes inside cells (&lt;code&gt;\|&lt;/code&gt;), alignment rows with colons. A naive regex-based parser falls apart here. I checked — writing a correct table parser by hand is a weekend of suffering I didn't need.&lt;/p&gt;

&lt;p&gt;The pragmatic answer is &lt;code&gt;marked&lt;/code&gt;. It's a real, maintained parser with proper GFM support:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;marked&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;marked&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;input&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;source&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;output&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;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;render&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;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;marked&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;gfm&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;breaks&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="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&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;Forty lines was aspirational; the honest count is a bit more once you add the rest. But the parsing itself is genuinely a few lines, because &lt;code&gt;marked&lt;/code&gt; does the heavy lifting — tokenizing, handling the table edge cases, nested lists, all of it.&lt;/p&gt;

&lt;p&gt;The lesson: don't hand-roll a Markdown parser in 2026. The spec edge cases are a tar pit. Use a real parser and spend your energy on the problems around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Markdown becomes HTML, and HTML bites
&lt;/h2&gt;

&lt;p&gt;Here's the one people skip: &lt;code&gt;marked.parse()&lt;/code&gt; returns HTML, and you inject it with &lt;code&gt;innerHTML&lt;/code&gt;. That means your previewer is an XSS delivery mechanism unless you sanitize.&lt;/p&gt;

&lt;p&gt;Markdown has plenty of HTML smuggling routes. A link like &lt;code&gt;[click](javascript:alert(1))&lt;/code&gt;. An image with an &lt;code&gt;onerror&lt;/code&gt; attribute in inline HTML. Markdown explicitly allows raw HTML passthrough, which is great for docs and terrible for a preview pane.&lt;/p&gt;

&lt;p&gt;The fix is DOMPurify, applied to every render with no exceptions:&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dompurify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;render&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;dirty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;marked&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gfm&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="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DOMPurify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sanitize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dirty&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;This isn't optional hardening — it's the difference between a tool and a vulnerability. Any time you render user-supplied Markdown as HTML, sanitize the output. Every time. There is no "it's just a preview" exemption; the preview runs in the user's browser with the user's session.&lt;/p&gt;

&lt;p&gt;One subtlety worth knowing: sanitizing is about the HTML output, not the Markdown input. Trying to blacklist "dangerous Markdown" is a losing game — the attack surface is the DOM, so that's where you defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Big documents turn keystrokes into slideshows
&lt;/h2&gt;

&lt;p&gt;Re-rendering on every keystroke feels instant on a 50-line README. Paste a 10,000-line changelog and the same approach turns typing into a slideshow — parse, sanitize, and re-layout on each keypress is genuinely expensive.&lt;/p&gt;

&lt;p&gt;The fix is a debounce, and it's the highest value-to-effort ratio in the whole project:&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;let&lt;/span&gt; &lt;span class="nx"&gt;timer&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="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&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;150 milliseconds is the sweet spot I landed on: short enough that the preview still feels live, long enough that fast typing coalesces into a single render. For truly huge documents you could get fancier — incremental rendering, virtualized output — but debounce plus a fast parser covers everything I've thrown at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: warn before you load remote images
&lt;/h2&gt;

&lt;p&gt;One more real-world gotcha: Markdown documents love remote images — &lt;code&gt;![logo](https://cdn.example.com/logo.png)&lt;/code&gt;. Rendering them means the reader's browser phones home to that server. For a preview tool, silently triggering those requests is rude at best.&lt;/p&gt;

&lt;p&gt;I added a lightweight pre-render scan that collects remote image URLs and surfaces them as a warning instead of loading them blindly:&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;findRemoteImages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&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;urls&lt;/span&gt; &lt;span class="o"&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;re&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;*&lt;/span&gt;&lt;span class="se"&gt;\]\((&lt;/span&gt;&lt;span class="sr"&gt;https&lt;/span&gt;&lt;span class="se"&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;)&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\)&lt;/span&gt;&lt;span class="sr"&gt;/g&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;m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;markdown&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urls&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;It's deliberately simple — a regex over the source, not the parsed AST — because it only needs to answer "are there external references?" before anything touches the network. The full tool pairs this with a Remote Asset Guard UI, but even the ten-line version changes the trust story: the preview never makes a network request the user didn't approve.&lt;/p&gt;

&lt;p&gt;Small feature, outsized trust payoff. The kind of thing you only add after someone pastes a doc full of tracking pixels.&lt;/p&gt;

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

&lt;p&gt;The result is &lt;a href="https://toolkitloop.com/markdown-viewer" rel="noopener noreferrer"&gt;Markdown Viewer&lt;/a&gt;: paste or open a &lt;code&gt;.md&lt;/code&gt; file, get an instant GFM preview, nothing leaves your browser. Along the way I added a few things the core loop needed — reading stats (words, headings, links, code blocks) as a structural sanity check, a guard that warns when your document references remote images, and a "passport" check that flags app-specific syntax like wiki links which won't survive a move to GitHub.&lt;/p&gt;

&lt;p&gt;None of those were in the original forty-line plan. They came from actually using the thing on real documents and hitting real paper cuts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;Here's my question for you: what's the weirdest Markdown edge case you've ever hit in the wild? Nested task lists inside blockquotes? A table that renders on GitHub but explodes in your static site generator? I'm collecting them — partly out of curiosity, partly because every one is a potential test case. Drop it in the comments.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Client-Side JSON Fixer That Repairs AI Output</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:34:12 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-client-side-json-fixer-that-repairs-ai-output-577p</link>
      <guid>https://dev.to/bowentse/i-built-a-client-side-json-fixer-that-repairs-ai-output-577p</guid>
      <description>&lt;p&gt;It started, as these things always do, at 1 AM. I had asked an LLM to generate a chunk of structured data — a few hundred lines of JSON for a seed script — and it came back looking perfect. I pasted it into my project, hit run, and Node handed me this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unexpected token } in JSON at position 812
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Position 812. Thanks, Node. That narrowed it down to... somewhere in the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real pain: AI output that &lt;em&gt;looks&lt;/em&gt; like JSON
&lt;/h2&gt;

&lt;p&gt;If you've worked with LLMs for more than a week, you know this ritual. The output looks pristine in the chat window, but it's a minefield. In my experience, broken AI-generated JSON fails for one of five reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Markdown code fences&lt;/strong&gt; — the model wraps everything in triple backticks. Those fences are not JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trailing commas&lt;/strong&gt; — &lt;code&gt;{ "id": 1, }&lt;/code&gt;. The model learned JavaScript's looser habits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unquoted keys&lt;/strong&gt; — &lt;code&gt;{ id: 1 }&lt;/code&gt; works in JS, dies in &lt;code&gt;JSON.parse&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smart quotes&lt;/strong&gt; — some chat UIs turn &lt;code&gt;"&lt;/code&gt; into “ and ”. Invisible until it breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comments&lt;/strong&gt; — models love adding &lt;code&gt;// like this&lt;/code&gt; to "helpful" config output. JSON doesn't allow them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's a typical specimen — spot the three violations before you scroll on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"seed-generator-v3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ada"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;id:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Grace"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;generated&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2026-10-15&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Answer: trailing commas after &lt;code&gt;"Ada",&lt;/code&gt; and &lt;code&gt;},&lt;/code&gt;, the unquoted key &lt;code&gt;id&lt;/code&gt;, and the &lt;code&gt;//&lt;/code&gt; comment.)&lt;/p&gt;

&lt;p&gt;Each one is a five-second fix. Finding it among 500 lines is the part that takes an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the usual fixes are annoying
&lt;/h2&gt;

&lt;p&gt;The obvious move is &lt;code&gt;JSON.parse&lt;/code&gt; in a scratch file and reading the error message. But native error messages are famously unhelpful — a position number with no context, or in some engines no position at all. Online validators are the next stop, and here's where I got picky: most of them send your paste to a server. When your JSON contains a staging API key or a chunk of real user data, that's a hard no for me.&lt;/p&gt;

&lt;p&gt;The third option — hand-rolling a fixer script — is what I actually did for a while. Here's the tiny trick that made &lt;code&gt;JSON.parse&lt;/code&gt; errors bearable:&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;pinpointError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&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;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;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// valid!&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="nx"&gt;err&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/position &lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&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;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="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="c1"&gt;// show 60 chars of context around the failure point&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;max&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;pos&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;20&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;This one snippet turned "position 812" into the actual offending character with context. Game changer. But I still had to strip code fences by hand, regex trailing commas myself, and — the sketchy part — I once "fixed" a payload by running a regex that ate a comma inside a string value. That was the moment I decided repair tools need a &lt;strong&gt;preview before apply&lt;/strong&gt; step, or they're just a fancier way to corrupt data.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I built the tool I kept wishing existed
&lt;/h2&gt;

&lt;p&gt;Full disclosure: &lt;strong&gt;I built this tool&lt;/strong&gt; — it's a &lt;a href="https://toolkitloop.com/json-validator" rel="noopener noreferrer"&gt;browser-local JSON syntax validator and debugging workspace&lt;/a&gt;. Everything I'm about to describe, I use myself whenever an API or an LLM hands me suspect JSON. Here's what the workflow looks like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Paste and validate.&lt;/strong&gt; No account, no upload. Paste in the tool and it checks syntax immediately, entirely inside your browser tab. You can verify the "no upload" claim by opening the Network panel — nothing leaves the page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Read a location-aware error message.&lt;/strong&gt; Instead of a bare position number, you get the line, the column, and the surrounding context. Plus two things plain validators skip: &lt;strong&gt;duplicate-key warnings&lt;/strong&gt; (technically valid JSON, but parsers silently keep only the last value — a silent data-loss bug) and &lt;strong&gt;interoperability notes&lt;/strong&gt; that flag constructs which parse in one language but behave differently in another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Use the safe repair preview.&lt;/strong&gt; This is the part I care about most. The preview handles the common copy-and-paste fixes — code fences, trailing commas, quote normalization, unquoted keys — and shows you the resulting document &lt;em&gt;before&lt;/em&gt; anything is applied. The fixes stay in the preview until you explicitly choose to use them. No regex roulette on your own data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Validate against a schema, still locally.&lt;/strong&gt; If you're generating fixtures for an API, you can paste an inline JSON Schema and validate against it in the same tab. Remote &lt;code&gt;$ref&lt;/code&gt; values are never fetched, so there's no network round-trip and no leaking your schema.&lt;/p&gt;

&lt;p&gt;The whole flow — paste, locate, preview-repair, schema-check — takes about three minutes for the typical "the model gave me 400 lines and one comma is wrong" case. You can try it here: &lt;a href="https://toolkitloop.com/json-validator" rel="noopener noreferrer"&gt;JSON Validator Online&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;AI models will keep emitting almost-JSON for the foreseeable future — they generate tokens, not grammars, so trailing commas and code fences are features of how they write, not bugs in your prompt. The fix isn't better prompting; it's a repair workflow that assumes the input is hostile: validate locally, see the exact error location, preview every repair before applying it, and never upload data you wouldn't email to a stranger.&lt;/p&gt;

&lt;p&gt;What's the weirdest broken-JSON-from-AI story you've hit? The fence-in-a-fence? A smart quote hiding in a 2,000-line file? Drop it in the comments — I want to know I'm not alone in this.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Zero-Backend Percentage Calculator: 5 Lessons</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Tue, 13 Oct 2026 03:20:24 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-zero-backend-percentage-calculator-5-lessons-27e6</link>
      <guid>https://dev.to/bowentse/i-built-a-zero-backend-percentage-calculator-5-lessons-27e6</guid>
      <description>&lt;p&gt;Every developer does percentage math daily — discount logic, progress bars, test coverage, "what share of users converted." And most of us reach for a calculator app or a quick &lt;code&gt;node -e&lt;/code&gt; one-liner without thinking twice about it.&lt;/p&gt;

&lt;p&gt;But percentages have sharp edges, especially in JavaScript. This post collects the five percentage tricks I actually use, with the code to match, and the lessons from shipping a free &lt;a href="https://toolkitloop.com/percentage-calculator" rel="noopener noreferrer"&gt;percentage calculator&lt;/a&gt; that runs entirely in the browser — zero backend, zero signup. (Disclosure: I built it — it's one of 30 browser-based tools I maintain.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Trick 1: &lt;code&gt;x * pct / 100&lt;/code&gt; — and know why the order matters
&lt;/h2&gt;

&lt;p&gt;The naive version:&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;percentOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;percentOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 12&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiply first, divide last. This isn't just style — doing &lt;code&gt;(value / 100) * pct&lt;/code&gt; introduces a rounding step earlier in the pipeline. Keep integers whole as long as you can.&lt;/p&gt;

&lt;p&gt;The reverse is equally common in UI code ("user typed a tip amount, what percent is that?"):&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;whatPercent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;whole&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;whole&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;whatPercent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 15&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Trick 2: the flip — &lt;code&gt;a% of b === b% of a&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This mental-math trick works in code too, as an optimization for readability: whenever one side is rounder, compute from that side. And it makes a great unit test:&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;percentOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 18% of 50 === 50% of 18 === 9&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;percentOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;percentOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your percent function ever fails that symmetry assertion, something is wrong with your rounding, not your math.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trick 3: the mental-math hacks, ported to code
&lt;/h2&gt;

&lt;p&gt;The tricks that make percentages fast in your head also make good, readable utility functions. The classic "10% + 5% + 1% building blocks" method:&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;tenPct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&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;fivePct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;20&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;onePct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 17% of 240 = 10% + 5% + 1% + 1%&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seventeenPctOf240&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nf"&gt;tenPct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;fivePct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;onePct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;onePct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 40.8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody writes production code this way — &lt;code&gt;240 * 0.17&lt;/code&gt; is shorter — but it's the same decomposition a good explainability UI uses when it shows its work ("10% = 24, 5% = 12, …"). If your product ever needs to &lt;em&gt;show&lt;/em&gt; a calculation step by step (finance, education, checkout breakdowns), decompose from round blocks instead of dumping one magic multiplication.&lt;/p&gt;

&lt;p&gt;Quarter shortcuts map to bit-shifts in the integer domain:&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;quarterOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cents&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;cents&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// exact for integers&lt;/span&gt;
&lt;span class="nf"&gt;quarterOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 2400 cents = $24.00, exactly 25% of $96&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No float involved, no rounding error possible. When your domain is money, staying in integers isn't a micro-optimization — it's the whole correctness strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trick 4: never trust floats — the 2.9985000000000004 problem
&lt;/h2&gt;

&lt;p&gt;Here's the one that bites in production. A 15% tip on a $19.99 bill:&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="mf"&gt;19.99&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 2.9985000000000004  — not 2.9985!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;IEEE 754 doubles can't represent 0.15 or 19.99 exactly, and percentage math multiplies those tiny errors into visible ones. Classic demos: &lt;code&gt;0.1 + 0.2&lt;/code&gt; gives &lt;code&gt;0.30000000000000004&lt;/code&gt;. In a finance-adjacent UI, that trailing &lt;code&gt;00000000004&lt;/code&gt; is a trust killer.&lt;/p&gt;

&lt;p&gt;Three fixes, from weakest to strongest:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix A — &lt;code&gt;toFixed&lt;/code&gt; at the boundary.&lt;/strong&gt; Fine for display, but it returns a &lt;em&gt;string&lt;/em&gt; and hides the error rather than removing it:&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="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;19.99&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&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;span class="c1"&gt;// "3.00"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix B — integer cents.&lt;/strong&gt; Do all math in the smallest unit, divide only when rendering:&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;cents&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;19.99&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// 1999&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tipCents&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;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cents&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 300&lt;/span&gt;
&lt;span class="nx"&gt;tipCents&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                              &lt;span class="c1"&gt;// 3.00 — exact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix C — a decimal-round helper for the general case:&lt;/strong&gt;&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;roundTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decimals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EPSILON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;decimals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;decimals&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;roundTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;19.99&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.15&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;span class="c1"&gt;// 3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Number.EPSILON&lt;/code&gt; nudge handles the cases where the float lands just below a &lt;code&gt;.5&lt;/code&gt; boundary. For the calculator I shipped, money paths use integer cents (Fix B) and general math uses the EPSILON helper (Fix C) — belt and suspenders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trick 5: percentage change without the sign bug
&lt;/h2&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;percentChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;oldVal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newVal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;newVal&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;oldVal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;oldVal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;percentChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 25&lt;/span&gt;
&lt;span class="nf"&gt;percentChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// -25&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas I've seen in real codebases: dividing by &lt;code&gt;newVal&lt;/code&gt; instead of &lt;code&gt;oldVal&lt;/code&gt; (changes the baseline), and forgetting that a "200% increase" means &lt;em&gt;tripled&lt;/em&gt;, not doubled. If you're rendering "+200%" in a dashboard, make sure your users read it the same way you wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shipping it: lessons from zero-backend
&lt;/h2&gt;

&lt;p&gt;The percentage calculator at &lt;a href="https://toolkitloop.com/percentage-calculator" rel="noopener noreferrer"&gt;toolkitloop.com/percentage-calculator&lt;/a&gt; is pure client-side HTML/CSS/JS. No API, no database, no analytics beacon phoning home with your numbers. Lessons from that architecture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validation is the whole backend.&lt;/strong&gt; Every input is a string until you prove otherwise — &lt;code&gt;parseFloat&lt;/code&gt; on empty input, comma decimals, pasted currency symbols. A calculator's input parser handles more cases than its math engine:
&lt;/li&gt;
&lt;/ol&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;parseAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&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;[^&lt;/span&gt;&lt;span class="sr"&gt;0-9.,-&lt;/span&gt;&lt;span class="se"&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleaned&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;n&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="c1"&gt;// null → show "enter a number"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Careful with the naive version above: on &lt;code&gt;"$1,299.99"&lt;/code&gt; it produces &lt;code&gt;"1.299.99"&lt;/code&gt;, which &lt;code&gt;parseFloat&lt;/code&gt; truncates to &lt;code&gt;1.299&lt;/code&gt;. Real-world parsing has to decide whether a comma is a thousands separator or a decimal mark &lt;em&gt;before&lt;/em&gt; stripping it — usually by checking which of &lt;code&gt;,&lt;/code&gt; or &lt;code&gt;.&lt;/code&gt; appears last. This is the unglamorous 80% of a "simple" tool: not the arithmetic, but the twenty ways humans type a number.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;State lives in the URL.&lt;/strong&gt; &lt;code&gt;?pct=15&amp;amp;value=80&lt;/code&gt; makes results shareable and bookmarkable — a poor man's API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge cases ARE the product.&lt;/strong&gt; Division by zero, negative percentages, &amp;gt;100% — a calculator that crashes on &lt;code&gt;0&lt;/code&gt; is worse than no calculator.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it and tell me your worst float bug
&lt;/h2&gt;

&lt;p&gt;Percent math is simple until floats get involved — then it's a rite of passage. What's the worst floating-point bug you've shipped or caught? &lt;code&gt;0.1 + 0.2&lt;/code&gt; stories especially welcome in the comments.&lt;/p&gt;

&lt;p&gt;And if you need exact percentage answers without reaching for &lt;code&gt;node -e&lt;/code&gt; every time, the free calculator is at &lt;a href="https://toolkitloop.com/percentage-calculator" rel="noopener noreferrer"&gt;toolkitloop.com/percentage-calculator&lt;/a&gt; — no signup, your numbers never leave your browser.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Character Counting Is Harder Than It Looks: A Dev's Guide</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Sun, 11 Oct 2026 04:24:55 +0000</pubDate>
      <link>https://dev.to/bowentse/character-counting-is-harder-than-it-looks-a-devs-guide-pdf</link>
      <guid>https://dev.to/bowentse/character-counting-is-harder-than-it-looks-a-devs-guide-pdf</guid>
      <description>&lt;p&gt;Every developer has written something like &lt;code&gt;if (text.length &amp;gt; 280)&lt;/code&gt;. And every developer with users who type emoji has watched that check break in production. Counting characters is one of those problems that looks trivial until a platform, an encoding, or a family emoji proves otherwise.&lt;/p&gt;

&lt;p&gt;Here is a field guide to the three counting regimes you will actually meet — and the code that handles each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regime 1: graphemes — what the reader sees
&lt;/h2&gt;

&lt;p&gt;A string is not a sequence of characters. It is a sequence of UTF-16 code units, and one visible character can be many of them:&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;👨‍👩‍👧‍👦&lt;/span&gt;&lt;span class="dl"&gt;"&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="c1"&gt;// 11 — but your eyes see ONE character&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;é&lt;/span&gt;&lt;span class="dl"&gt;"&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="c1"&gt;// 2 if built from e + combining accent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is the grapheme cluster — what a reader perceives as a single character — and the browser already has an API for it:&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;segmenter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en&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;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;grapheme&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;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;segmenter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;👨‍👩‍👧‍👦&lt;/span&gt;&lt;span class="dl"&gt;"&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="c1"&gt;// 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you build any "max N characters" validation, count segments, not &lt;code&gt;.length&lt;/code&gt;. Your users' emoji will thank you. (One caveat: &lt;code&gt;Intl.Segmenter&lt;/code&gt; support is broad in modern browsers but check your target — Node 16+ and all evergreen browsers have it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Regime 2: X's weighted counting — URLs cost 23, always
&lt;/h2&gt;

&lt;p&gt;A standard X post allows 280 &lt;em&gt;weighted&lt;/em&gt; characters. The weighting rules (in the open-source &lt;a href="https://github.com/twitter/twitter-text" rel="noopener noreferrer"&gt;twitter-text&lt;/a&gt; config) mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every URL counts as &lt;strong&gt;23 characters&lt;/strong&gt;, no matter its real length — X wraps links in its own shortener.&lt;/li&gt;
&lt;li&gt;Some ranges (emoji included) weigh more than 1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So this announcement — 193 characters as your eyes count them — is only about &lt;strong&gt;169 weighted&lt;/strong&gt;: the 47-character URL collapses to 23 while the rocket counts double.&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="c1"&gt;// sketch of the idea, not the full config&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;weightedLength&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;urlRegex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/https&lt;/span&gt;&lt;span class="se"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withoutUrls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&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="nx"&gt;urlRegex&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;urls&lt;/span&gt; &lt;span class="o"&gt;=&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="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urlRegex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&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="c1"&gt;// emoji &amp;amp; wide ranges weigh 2 (simplified)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;withoutUrls&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ch&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;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Extended_Pictographic}/u&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;ch&lt;/span&gt;&lt;span class="p"&gt;)&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;span class="mi"&gt;1&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;return&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;23&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;Moral: never validate against X's limit with a plain character count. Use the platform's rule — or a tool that applies it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regime 3: SMS segments — the encoding tax
&lt;/h2&gt;

&lt;p&gt;SMS is counted in &lt;strong&gt;segments&lt;/strong&gt;, and the segment size depends on encoding:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Encoding&lt;/th&gt;
&lt;th&gt;Single message&lt;/th&gt;
&lt;th&gt;Concatenated segments&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GSM-7 (plain Latin)&lt;/td&gt;
&lt;td&gt;160 chars&lt;/td&gt;
&lt;td&gt;153 chars each&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UCS-2 (emoji/CJK)&lt;/td&gt;
&lt;td&gt;70 chars&lt;/td&gt;
&lt;td&gt;67 chars each&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One emoji flips the &lt;em&gt;entire message&lt;/em&gt; to UCS-2. Even &lt;code&gt;€&lt;/code&gt; costs two GSM-7 units. A 165-character marketing text with one emoji can silently become three billable segments instead of two. If you build anything that sends SMS, surface the segment count before the user hits send.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool I built for all three
&lt;/h2&gt;

&lt;p&gt;Disclosure first: I built &lt;a href="https://toolkitloop.com/character-counter" rel="noopener noreferrer"&gt;toolkitloop.com/character-counter&lt;/a&gt; — one of 30 free browser tools I maintain. It counts visible graphemes (family emoji = 1), has an &lt;strong&gt;X mode&lt;/strong&gt; that applies the weighted URL rule, shows with/without-spaces totals, and runs 100% client-side — no signup, nothing uploaded. Handy as a pre-publish check before the platform's own composer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;What is the worst character-counting bug you have shipped or seen? The emoji-that-ate-the-limit? The SMS bill surprise? Drop it in the comments — I am collecting edge cases to make the tool smarter.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built a Client-Side Decision Wheel: The Randomness Bit</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Fri, 09 Oct 2026 02:55:44 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-client-side-decision-wheel-the-randomness-bit-4cah</link>
      <guid>https://dev.to/bowentse/i-built-a-client-side-decision-wheel-the-randomness-bit-4cah</guid>
      <description>&lt;p&gt;Group decisions are where friendships go to stall. "Where do we eat?" "Who goes first?" "Who does the dishes?" — everyone has a preference, nobody wants to be the decider. So I built a small spinning wheel that takes the call: type a question, spin, get YES or NO. Three seconds, zero arguments.&lt;/p&gt;

&lt;p&gt;The spin animation is the fun part. The interesting part — the part that actually matters if anyone's running a giveaway — is &lt;strong&gt;how the wheel picks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I built this tool at &lt;a href="https://toolkitloop.com/yes-no-wheel" rel="noopener noreferrer"&gt;toolkitloop.com/yes-no-wheel&lt;/a&gt;. It's one of 30 free client-side tools I maintain. The code patterns below are general; steal them.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive version everyone writes
&lt;/h2&gt;

&lt;p&gt;The first implementation of any wheel looks like this:&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;segments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;YES&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NO&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;spin&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="nx"&gt;segments&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;floor&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;segments&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Math.random()&lt;/code&gt; is fine for picking lunch. But it has two properties that should make you pause before using it for anything people care about:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's not cryptographically secure.&lt;/strong&gt; The sequence is deterministic given the internal state. Nobody's going to reverse-engineer your lunch wheel, but "provably fair" giveaway draws deserve better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modulo bias.&lt;/strong&gt; &lt;code&gt;Math.floor(Math.random() * n)&lt;/code&gt; is &lt;em&gt;almost&lt;/em&gt; uniform, but with certain ranges the distribution isn't perfectly even. For a 2-segment wheel the effect is negligible; for a 47-entrant giveaway wheel, tiny biases are exactly the kind of thing a sore loser will (correctly) point at.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The fair version: crypto.getRandomValues + rejection sampling
&lt;/h2&gt;

&lt;p&gt;The Web Crypto API gives us real entropy from the OS. The trick is converting random bytes into a uniform index without modulo bias — that's what rejection sampling does:&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="c1"&gt;// Unbiased random index in [0, n) using Web Crypto&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fairIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0xffffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Largest multiple of n that fits in 32 bits — reject anything above it&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limit&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="p"&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;n&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;buf&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;Uint32Array&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;limit&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;x&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;spinWheel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;segments&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="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;fairIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;segments&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop rejects the leftover range that would make some indices slightly more likely, then maps the remainder uniformly. The expected number of iterations is barely above 1 — the &lt;code&gt;do...while&lt;/code&gt; almost never repeats. This is a ~15-line function, runs entirely client-side, and gives you a draw you can defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separating selection from animation
&lt;/h2&gt;

&lt;p&gt;One design decision that made the code much cleaner: &lt;strong&gt;the animation is theater; the selection is math.&lt;/strong&gt; The wheel's final resting angle is computed from the already-chosen index, and the spin animation just plays out to that angle:&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="c1"&gt;// Selection happens first; animation follows&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;winnerIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fairIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;segments&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sliceAngle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;segments&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="c1"&gt;// Land the pointer in the middle of the winning slice,&lt;/span&gt;
&lt;span class="c1"&gt;// after a few full rotations for drama&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;finalAngle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;winnerIndex&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;sliceAngle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;sliceAngle&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;wheel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`rotate(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;finalAngle&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;deg)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This separation has a nice side effect: you can unit-test fairness (run 100k spins, check the distribution) without touching any animation code. If you're building any kind of picker, test the distribution — chi-square is overkill, but eyeballing a histogram of 10k draws catches real bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything stays on the device
&lt;/h2&gt;

&lt;p&gt;The whole thing — entropy, selection, rendering — happens in the browser. No server round-trip, no list of entrant names uploaded anywhere. For a giveaway tool this isn't just a nice privacy story; it removes an entire class of "what did you do with my email list?" questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it / take it apart
&lt;/h2&gt;

&lt;p&gt;The tool is free and needs no account: &lt;a href="https://toolkitloop.com/yes-no-wheel" rel="noopener noreferrer"&gt;toolkitloop.com/yes-no-wheel&lt;/a&gt; — yes/no mode for quick calls, custom segments for name wheels and giveaways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question for the comments:&lt;/strong&gt; have you ever caught &lt;code&gt;Math.random()&lt;/code&gt;-style bias causing a real problem — a lopsided A/B test, a "random" shuffle that wasn't, a giveaway dispute? I'd love to hear the war stories. 👇&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to Scan QR Codes from Screenshots Without an App</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Wed, 07 Oct 2026 12:23:13 +0000</pubDate>
      <link>https://dev.to/bowentse/how-to-scan-qr-codes-from-screenshots-without-an-app-2le4</link>
      <guid>https://dev.to/bowentse/how-to-scan-qr-codes-from-screenshots-without-an-app-2le4</guid>
      <description>&lt;p&gt;Every developer has lived this one. A QR code shows up &lt;em&gt;on your own screen&lt;/em&gt; — a 2FA setup code in a desktop app, a Wi-Fi password in a chat message, a ticket in an email — and your phone's camera is suddenly useless. You can't photograph your own display without a second device and a lot of glare.&lt;/p&gt;

&lt;p&gt;The usual workarounds are all bad: save the image, email it to yourself, scan it with your phone. Or my personal low point: taking a photo of my monitor with my phone and squinting at the result.&lt;/p&gt;

&lt;p&gt;There's a much better way, and it runs entirely in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: your clipboard is an input device
&lt;/h2&gt;

&lt;p&gt;Modern browsers let a web page read an image straight from your clipboard. So the whole workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Screenshot the code however you normally do (Win+Shift+S, Cmd+Shift+4, phone screenshot button). Don't bother saving — it's in your clipboard.&lt;/li&gt;
&lt;li&gt;Open &lt;a href="https://toolkitloop.com/qr-code-scanner" rel="noopener noreferrer"&gt;this free QR scanner&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Click the page, press &lt;strong&gt;Ctrl+V&lt;/strong&gt; (Cmd+V on Mac).&lt;/li&gt;
&lt;li&gt;Read the decoded result: full link or text, code format, and a link-safety preview.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five seconds, no install, no account. The same page also handles file uploads (for saved screenshots) and camera scans (for printed codes), and it reads QR, UPC, and common 1D/2D formats without asking you to pick a mode.&lt;/p&gt;

&lt;p&gt;(Disclosure: I built this tool. It's one of 30 free browser-based utilities I'm shipping at ToolkitLoop — everything runs client-side, no backend.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually happening under the hood
&lt;/h2&gt;

&lt;p&gt;If you're curious how a paste-to-decode flow works, the pipeline is refreshingly simple. Conceptually:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture&lt;/strong&gt;: the page receives the clipboard image as a bitmap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rasterize&lt;/strong&gt;: it draws that bitmap onto a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; so it can read raw pixels via &lt;code&gt;getImageData&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decode&lt;/strong&gt;: a decoding library converts the pixel grid into text — finding the QR finder patterns, sampling the module grid, running error correction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry passes&lt;/strong&gt;: for damaged inputs, the pipeline re-runs on transformed copies — rotated, inverted, scaled — until something decodes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An illustrative sketch of the shape (not the site's actual source):&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="c1"&gt;// Illustrative pipeline — the general pattern, not production code&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeFromScreenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageBitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decode&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;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="s2"&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;imageBitmap&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;imageBitmap&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="s2"&gt;2d&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;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageBitmap&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pixels&lt;/span&gt; &lt;span class="o"&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;getImageData&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="c1"&gt;// Try the straight decode first, then rescue passes&lt;/span&gt;
  &lt;span class="c1"&gt;// (rotation, inversion, deeper analysis) for damaged codes&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;const&lt;/span&gt; &lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rotate90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;invert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;upscale&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pixels&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;result&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;result&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="kc"&gt;null&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;The interesting bit is that &lt;strong&gt;all of this happens on your device&lt;/strong&gt;. Screenshots frequently contain sensitive stuff — chat threads, tickets, payment screens — so a scanner that uploads your image to a server is a privacy smell. Client-side decoding means the pixels never leave the browser tab. Camera permission, likewise, is only requested when you explicitly choose camera mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rescue Mode: for the ugly screenshots
&lt;/h2&gt;

&lt;p&gt;Real-world screenshots are hostile inputs: dark-mode inverted colors, notification banners overlapping the code, tiny codes in a corner of a 4K screenshot. The scanner's Rescue Mode retries locally with rotation, inversion, and deeper analysis passes.&lt;/p&gt;

&lt;p&gt;Practical tips when a scan fails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Crop tight&lt;/strong&gt; around the code before scanning — less noise, better decode rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dark mode screenshots&lt;/strong&gt; invert the colors; an inversion retry pass handles this automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enlarge small codes&lt;/strong&gt; — zoom the image and re-screenshot rather than scanning a 40px thumbnail.&lt;/li&gt;
&lt;li&gt;Make sure all three QR finder squares are visible; a clipped code can't decode no matter what.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The security habit worth building
&lt;/h2&gt;

&lt;p&gt;A decoder can show you the destination, but it can't decide for you. QR codes are a classic phishing vector — a sticker over a restaurant's real code can point at a lookalike login page. The preview-first flow exists so you can check the actual domain before opening anything, especially for codes that arrived as screenshots where visual context is easy to fake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Always read the decoded URL before tapping through.&lt;/strong&gt; It's a two-second habit that kills an entire class of attacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Next time a QR code lands on your own screen: screenshot, Ctrl+V, done. The scanner is free, no signup, at &lt;a href="https://toolkitloop.com/qr-code-scanner" rel="noopener noreferrer"&gt;toolkitloop.com/qr-code-scanner&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What's the most awkward "QR code on my own screen" situation you've hit — and how did you solve it before you knew the paste trick? Curious what workflows people have invented for this.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>productivity</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to Convert Decimals to Fractions: 3 Steps With Examples</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Mon, 05 Oct 2026 03:00:30 +0000</pubDate>
      <link>https://dev.to/bowentse/how-to-convert-decimals-to-fractions-3-steps-with-examples-41fi</link>
      <guid>https://dev.to/bowentse/how-to-convert-decimals-to-fractions-3-steps-with-examples-41fi</guid>
      <description>&lt;p&gt;Your API returns &lt;code&gt;0.375&lt;/code&gt;. The design mock says "⅜". The recipe your app is scaling says 0.666 cups but the user owns measuring cups marked in quarters. Somewhere between the machine world and the human world, decimals have to become fractions — and every developer, designer, or DIYer hits this gap eventually.&lt;/p&gt;

&lt;p&gt;The good news: the conversion is a 3-step mechanical process. No memorization, no algebra tricks. Here's the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method in 30 seconds
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the decimal over its place value.&lt;/strong&gt; One decimal place → over 10. Two → over 100. Three → over 1000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the greatest common divisor&lt;/strong&gt; of the top and bottom numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Divide both by the GCD.&lt;/strong&gt; That's your simplified fraction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. 0.75 → 75/100 → GCD(75, 100) = 25 → &lt;strong&gt;3/4&lt;/strong&gt;. Everything below is just this pattern applied to different numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the place-value trick works
&lt;/h2&gt;

&lt;p&gt;A decimal is already a fraction in disguise. "0.75" literally means "75 hundredths" — the digits after the point tell you the denominator directly. You're not computing anything in step 1; you're just rewriting notation.&lt;/p&gt;

&lt;p&gt;The only real math is step 3: simplification. And even that has a boringly reliable procedure — divide both numbers by 2, 3, 5, whatever divides them evenly, until nothing divides both. What survives is the GCD.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked examples (the ones you'll actually look up)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;0.5&lt;/strong&gt; → 5/10 → GCD(5,10) = 5 → &lt;strong&gt;1/2&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0.75&lt;/strong&gt; → 75/100 → GCD(75,100) = 25 → &lt;strong&gt;3/4&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0.125&lt;/strong&gt; → 125/1000 → GCD(125,1000) = 125 → &lt;strong&gt;1/8&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0.6&lt;/strong&gt; → 6/10 → GCD(6,10) = 2 → &lt;strong&gt;3/5&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1.25&lt;/strong&gt; → separate the whole number: 1 + 0.25 → 25/100 → GCD(25,100) = 25 → &lt;strong&gt;1 1/4&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Notice the pattern: the denominator is always 10, 100, or 1000 at first, then it collapses. If you remember &lt;em&gt;only one thing&lt;/em&gt;, remember this: count the decimal places, that's your zeros.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one case that breaks the pattern
&lt;/h2&gt;

&lt;p&gt;Repeating decimals — 0.333…, 0.1666… — never terminate, so they can't be written over a power of 10. 0.333… = 1/3, but getting there needs a different technique (multiply by 10, subtract, solve). In practice, this is exactly where you stop doing it by hand and use a tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 10-second demo: the tool I built (disclosure: it's mine)
&lt;/h2&gt;

&lt;p&gt;Full disclosure: I built this. It's one of 30 free utilities on &lt;a href="https://toolkitloop.com" rel="noopener noreferrer"&gt;ToolkitLoop&lt;/a&gt;, and it's 100% client-side — your numbers never leave the browser.&lt;/p&gt;

&lt;p&gt;Type a decimal and you get the simplified fraction instantly, with each simplification step shown so you can verify the math instead of trusting a black box:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F77zgg1k9x70er9li0rfv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F77zgg1k9x70er9li0rfv.png" alt="Decimal-to-fraction converter showing 0.75 converted to 3/4 with the simplification steps" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It also handles the awkward cases — repeating decimals, mixed numbers like 1.25, and longer decimals where the GCD isn't obvious:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn9fr7zsgsmf101bb7yrz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn9fr7zsgsmf101bb7yrz.png" alt="Tool FAQ showing step-by-step simplification: 0.75 = 75/100, divide both by 25 to get 3/4" width="800" height="416"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try it here: &lt;a href="https://toolkitloop.com/decimal-to-fraction" rel="noopener noreferrer"&gt;decimal to fraction converter&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheat sheet worth bookmarking
&lt;/h2&gt;

&lt;p&gt;These ten cover nearly every real-world lookup — recipes, tape measures, CSS values, grades:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decimal&lt;/th&gt;
&lt;th&gt;Fraction&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.125&lt;/td&gt;
&lt;td&gt;1/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;td&gt;1/5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;td&gt;1/4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.333…&lt;/td&gt;
&lt;td&gt;1/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.375&lt;/td&gt;
&lt;td&gt;3/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5&lt;/td&gt;
&lt;td&gt;1/2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.625&lt;/td&gt;
&lt;td&gt;5/8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.666…&lt;/td&gt;
&lt;td&gt;2/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;3/4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.875&lt;/td&gt;
&lt;td&gt;7/8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're building anything that displays measurements to humans — a recipe scaler, a dashboard, a CNC readout — keep this table one keystroke away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: the same method in 8 lines of JavaScript
&lt;/h2&gt;

&lt;p&gt;Since this is dev.to, here's the whole 3-step method as code — place value, GCD via Euclid's algorithm, simplify:&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;gcd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;gcd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decimalToFraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&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;places&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&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="nx"&gt;length&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;denom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="nx"&gt;places&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// step 1: place value&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;num&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;denom&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;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gcd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;denom&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// step 2: GCD&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;num&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;denom&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// step 3: simplify&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;decimalToFraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// "3/4"&lt;/span&gt;
&lt;span class="nf"&gt;decimalToFraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.125&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "1/8"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caveat: this handles terminating decimals only — same as the manual method. Repeating decimals and floating-point dust (looking at you, &lt;code&gt;0.1 + 0.2&lt;/code&gt;) need a tolerance-based approach, which is exactly why the browser tool linked above exists: it shows the steps so you're never debugging a black box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Decimals come from machines. Fractions come from humans. Converting is a 3-step translation: place value, GCD, simplify. For one-off conversions, do it by hand; for real work, bookmark the tool.&lt;/p&gt;

&lt;p&gt;What's the weirdest decimal-to-fraction conversion you've had to do in a project? I'm curious where this actually shows up outside school math — drop it in the comments.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
