<?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 a Batch Image Resizer That Never Uploads a Photo</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Sat, 31 Oct 2026 09:47:26 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-batch-image-resizer-that-never-uploads-a-photo-5ch1</link>
      <guid>https://dev.to/bowentse/i-built-a-batch-image-resizer-that-never-uploads-a-photo-5ch1</guid>
      <description>&lt;p&gt;I built a batch image resizer that never uploads a photo. Here is the part I did not expect to be interesting: the resize itself is ten lines of canvas code, and almost every bad result comes from the geometry around those ten lines, not from the drawing call.&lt;/p&gt;

&lt;p&gt;Disclosure up front: I built this tool — the free image resizer at &lt;a href="https://toolkitloop.com/image-resizer" rel="noopener noreferrer"&gt;https://toolkitloop.com/image-resizer&lt;/a&gt;. It resizes up to 10 images in the browser, in exact pixels or by percentage, with Fit, Crop or Pad modes, and downloads the batch as a ZIP. This post is about the decisions inside it, because they are the same decisions you will hit in any image pipeline you build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The annoying version of this problem
&lt;/h2&gt;

&lt;p&gt;The usual flow for resizing an image is: upload to a server, wait, download the result, repeat for the next nine images. For a single thumbnail that is tolerable. For a batch — ten product photos, a folder of screenshots — it is a slow, leaky loop, and every upload is a copy of your file living somewhere you do not control.&lt;/p&gt;

&lt;p&gt;The browser already has everything needed to do this locally: &lt;code&gt;createImageBitmap&lt;/code&gt; decodes the file, &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; draws it at a new size, and &lt;code&gt;toBlob&lt;/code&gt; hands back the result. No server, no queue, no upload. The interesting work is deciding &lt;em&gt;what rectangle to draw&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fit, crop, pad: three rectangles, three promises
&lt;/h2&gt;

&lt;p&gt;Given a source image of &lt;code&gt;sw × sh&lt;/code&gt; and a target of &lt;code&gt;tw × th&lt;/code&gt;, the scale factor for Fit is the smaller of the two ratios; for Crop it is the larger. That one-line difference is the whole feature:&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;fitRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;th&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crop&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;tw&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;th&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sh&lt;/span&gt;&lt;span class="p"&gt;)&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tw&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;th&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sh&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;dw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sw&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sh&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Fit: center the smaller rect, leaving empty space.&lt;/span&gt;
  &lt;span class="c1"&gt;// Crop: center the larger rect, letting edges overflow.&lt;/span&gt;
  &lt;span class="c1"&gt;// Pad: same as Fit, then fill the canvas background first.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tw&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;dw&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="na"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;th&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;dh&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="nx"&gt;dw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dh&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;Fit promises "nothing is cut off" and pays for it with empty space. Crop promises "the box is exactly full" and pays for it with trimmed edges. Pad is Fit plus a background fill, so the box is full and nothing is cut. If you have ever wondered why two resizers disagree on the same input, it is almost always because they silently chose different promises. Making the choice explicit — and previewing it — removes a whole class of bug reports.&lt;/p&gt;

&lt;p&gt;The draw itself is unremarkable, and that is the point:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resizeImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;th&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;th&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dh&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fitRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;th&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dh&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="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertToBlob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&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="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;blob&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;Two details worth keeping. First, &lt;code&gt;bitmap.close()&lt;/code&gt; matters in a batch loop: ten decoded images held open at once will eat memory you could have freed. Second, the output format is a decision, not a default. Re-encoding a JPEG as PNG can balloon the file; re-encoding a PNG with transparency as JPEG destroys the transparency. Keep the output format close to the input unless you have a reason to change it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The batch problem is not the loop
&lt;/h2&gt;

&lt;p&gt;Resizing ten images is a loop over the function above. The batch problem is everything around the loop: progress reporting per file, one failure that should not kill the other nine, and a single download at the end instead of ten save dialogs. A ZIP download at the end is what makes batch feel like batch; without it you have just done the same single-image job ten times.&lt;/p&gt;

&lt;p&gt;There is also a quiet privacy consequence worth naming. When resizing happens locally, the tool never sees the image at all — no filenames, no contents, no metadata sent to analytics. Resized exports are created without original metadata where the browser pipeline supports it. That is not a marketing line; it is what falls out of never uploading in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Upscaling: the honest limitation
&lt;/h2&gt;

&lt;p&gt;Every resizer has one limitation it should admit: enlarging cannot create detail. A 200-pixel icon stretched to 1200 pixels will look soft in every implementation, because the information was never there. The honest behaviour is to let the user do it, show the preview, and not pretend an algorithm recovered something. Downscaling is forgiving; upscaling is where previews earn their keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three lessons I would keep for the next image tool
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Name the geometry promise.&lt;/strong&gt; Fit, Crop and Pad are not cosmetic options; they are different contracts with the user. Silent defaults here are bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview before download, especially for crops.&lt;/strong&gt; A crop that cuts a face is obvious at preview size and expensive after re-upload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch is a UX feature, not a loop.&lt;/strong&gt; Per-file progress, isolated failures, and one ZIP download are what make ten images feel different from one image ten times.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you have built an image pipeline, which failure mode bit you first — the geometry, the format re-encode, or the batch download? I keep finding new edge cases in the crop preview, so I am curious where yours live.&lt;/p&gt;

&lt;p&gt;Try the tool this came from: &lt;a href="https://toolkitloop.com/image-resizer" rel="noopener noreferrer"&gt;https://toolkitloop.com/image-resizer&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Word Counting Is Harder Than It Looks: A Dev's Guide</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Thu, 29 Oct 2026 02:58:56 +0000</pubDate>
      <link>https://dev.to/bowentse/word-counting-is-harder-than-it-looks-a-devs-guide-1jpb</link>
      <guid>https://dev.to/bowentse/word-counting-is-harder-than-it-looks-a-devs-guide-1jpb</guid>
      <description>&lt;p&gt;Every developer has written the naive version at least once:&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;words&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;trim&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="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="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works on a clean paragraph. Then a user pastes an essay and the number is wrong, and the bug report says your counter disagrees with Word, with Google Docs, and with itself on different browsers. The disagreement is not a rounding error. It is a segmentation problem, and once you see the rules, the naive version looks brave rather than correct.&lt;/p&gt;

&lt;p&gt;Disclosure first: I built a free browser word counter at &lt;a href="https://toolkitloop.com/word-counter" rel="noopener noreferrer"&gt;https://toolkitloop.com/word-counter&lt;/a&gt;, and this post explains the segmentation behaviour its own page documents. The tool counts entirely in the browser — nothing is uploaded — which is the right architecture for a counter, because pasting an unpublished essay into a server is a privacy ask nobody should have to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive split fails in specific, predictable places
&lt;/h2&gt;

&lt;p&gt;Splitting on whitespace assumes every word is separated by a space. Real text breaks that assumption in at least five ways, and the ToolkitLoop tool page lists them as worked examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;don't&lt;/code&gt; is 1 word. An apostrophe stays inside a word.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;well-known&lt;/code&gt; is 1 word. A hyphenated compound stays together.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hello—world&lt;/code&gt; is 2 words. An em dash separates words.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;3.14&lt;/code&gt; is 1 word. A decimal number counts as one word.&lt;/li&gt;
&lt;li&gt;An emoji is 0 words, but it is still a character.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A whitespace split gets &lt;code&gt;don't&lt;/code&gt; and &lt;code&gt;well-known&lt;/code&gt; right by accident, gets &lt;code&gt;hello—world&lt;/code&gt; wrong (it sees one token), gets &lt;code&gt;3.14&lt;/code&gt; right by accident, and counts emoji as words, which users find plainly wrong. Worse, it fails completely on text that does not use spaces between words at all. Mixed text such as Chinese and English is segmented without relying on spaces alone, and a regex split has no answer for where one Chinese word ends and the next begins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The browser already has a segmenter: use it
&lt;/h2&gt;

&lt;p&gt;Modern browsers expose &lt;code&gt;Intl.Segmenter&lt;/code&gt;, a language-aware segmentation API built for exactly this job. A word counter built on it 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;function&lt;/span&gt; &lt;span class="nf"&gt;countWords&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="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="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Segmenter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fallback for older browsers: whitespace split, documented as approximate.&lt;/span&gt;
    &lt;span class="k"&gt;return&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;trim&lt;/span&gt;&lt;span class="p"&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;trim&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="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="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="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="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="kc"&gt;undefined&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;word&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isWordLike&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&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="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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isWordLike&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;count&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;Two details matter here. First, &lt;code&gt;isWordLike&lt;/code&gt; is the filter that keeps emoji out of the word count while leaving them in the character count — the exact behaviour users expect. Second, the locale argument: passing &lt;code&gt;undefined&lt;/code&gt; lets the browser use its default locale, which is a reasonable default for a general-purpose tool, but a counter aimed at one language should pass that locale explicitly and say so. Segmentation is locale-sensitive, and silently using the wrong locale is how you get a counter that is confident and wrong.&lt;/p&gt;

&lt;p&gt;Character counts deserve the same care. Counting characters with &lt;code&gt;text.length&lt;/code&gt; counts UTF-16 code units, not what a user sees. An emoji outside the basic multilingual plane is two code units, and a flag or family emoji is several. If your UI promises a "character count" and a user pastes emoji, &lt;code&gt;length&lt;/code&gt; will over-count in a way that is visible and annoying. &lt;code&gt;Intl.Segmenter&lt;/code&gt; with &lt;code&gt;granularity: "grapheme"&lt;/code&gt; gives you the user-visible units; use it for the character number you display next to the word number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sentences, paragraphs and reading time are estimates, not facts
&lt;/h2&gt;

&lt;p&gt;A counter usually shows more than words. Each extra number has its own failure mode, and honest UI copy should reflect that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sentences&lt;/strong&gt; depend on terminator detection. Abbreviations (&lt;code&gt;Dr.&lt;/code&gt;, &lt;code&gt;e.g.&lt;/code&gt;), decimals (&lt;code&gt;3.14&lt;/code&gt;) and ellipses all break naive period-splitting. &lt;code&gt;Intl.Segmenter&lt;/code&gt; with &lt;code&gt;granularity: "sentence"&lt;/code&gt; handles the common cases better than a regex, but no segmenter resolves every abbreviation correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paragraphs&lt;/strong&gt; are the most reliable number: split on blank lines and you are usually right, provided you decide whether a single line break is a paragraph break or a soft wrap in the pasted text. Document that choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading time&lt;/strong&gt; is an estimate of time, not a measure of quality. It is computed from a words-per-minute assumption that varies by audience and purpose. Present it as "about N min", never as a precise figure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ToolkitLoop page states this plainly: reading time estimates how long a reader may spend with the text, not whether the copy is good or complete. That sentence belongs in your UI copy too, because users will otherwise treat the estimate as a grade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why counters disagree — and what to tell users
&lt;/h2&gt;

&lt;p&gt;When a user says "Word says 512, your tool says 508", the correct response is not to defend your number. It is to explain that different tools apply different segmentation rules, and that the destination system is the authority for a strict limit. The practical advice that follows is genuinely useful:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Paste the exact version you intend to submit, headings and bullets included, because counters count what you paste.&lt;/li&gt;
&lt;li&gt;Edit with your counter as a guardrail, not a judge.&lt;/li&gt;
&lt;li&gt;Verify the final text in the system that receives it — the editor, CMS or application portal — before submitting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third step is the one most counter UIs forget to say. A counter that claims to be the final authority is lying about a problem it cannot solve, since it cannot know the portal's rules. A counter that says "use the destination's count for a strict rule" is more trustworthy, and trust is the only moat a free tool like this has.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building it locally is the whole point
&lt;/h2&gt;

&lt;p&gt;There is a reason this kind of tool belongs in the browser with no backend. The input is often unpublished: an essay, a job application, a client draft, a manuscript. Asking a user to upload that text to count its words is a privacy cost with no technical justification — segmentation runs fine on the client. The tool page's promise is simple: counting runs locally in the browser, the text is not sent to a server or analytics service, and a word goal or limit setting stays on the user's own device. If you build a counter, that local-first shape is not a nice-to-have; it is the feature that makes the tool safe to recommend to students and writers.&lt;/p&gt;

&lt;p&gt;The same reasoning applies to the rest of the numbers. Characters, sentences, paragraphs and reading time are all derivable on the client. Nothing in this feature needs a server, an account or a database. When a tool's value proposition is "paste your unfinished work here", the architecture is the privacy policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist I would ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;Intl.Segmenter&lt;/code&gt; with &lt;code&gt;granularity: "word"&lt;/code&gt; and filter on &lt;code&gt;isWordLike&lt;/code&gt;; keep a documented whitespace fallback for older browsers.&lt;/li&gt;
&lt;li&gt;Count characters as graphemes, not UTF-16 code units.&lt;/li&gt;
&lt;li&gt;Show words, characters, sentences, paragraphs and reading time together, because each answers a different question.&lt;/li&gt;
&lt;li&gt;Say plainly that the destination system's count wins for a strict limit.&lt;/li&gt;
&lt;li&gt;Keep everything local: no upload, no account, no analytics on the pasted text.&lt;/li&gt;
&lt;li&gt;Let users set a word goal or limit that persists on their device.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try the edge cases in your own counter and tell me which one surprises you most: is it the em dash splitting &lt;code&gt;hello—world&lt;/code&gt;, the emoji counting as zero words, or the mixed-language text that breaks a whitespace split entirely? The answer usually tells you which assumption your implementation is still hiding.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Why Dynamic QR Codes Break: A Static-Code Fix for Devs</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Tue, 27 Oct 2026 02:57:11 +0000</pubDate>
      <link>https://dev.to/bowentse/why-dynamic-qr-codes-break-a-static-code-fix-for-devs-593b</link>
      <guid>https://dev.to/bowentse/why-dynamic-qr-codes-break-a-static-code-fix-for-devs-593b</guid>
      <description>&lt;p&gt;Disclosure first, because this is a tool post: I built the QR code generator at ToolkitLoop, and this article explains a failure mode I keep seeing in support threads — a printed QR code that "suddenly expired." It almost never expired. Something behind it died, and you can prove which thing in about a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model most people get wrong
&lt;/h2&gt;

&lt;p&gt;A QR code is not a link. It is a payload, and what the payload contains decides everything about how it can fail.&lt;/p&gt;

&lt;p&gt;A static QR code's payload &lt;em&gt;is&lt;/em&gt; the content: &lt;code&gt;https://example.com/menu&lt;/code&gt;, a Wi-Fi credential block, a vCard. The scanner reads it, and there is nothing else to go wrong except the destination itself.&lt;/p&gt;

&lt;p&gt;A dynamic QR code's payload is a pointer: something like &lt;code&gt;https://short.example/abc123&lt;/code&gt;. The scanner reads the pointer, the browser asks that short domain where to go, and only then does the visitor reach your page. That extra hop is the whole product — it is what lets you change the destination without reprinting — and it is also a single point of failure you do not own.&lt;/p&gt;

&lt;p&gt;So when someone says "my free QR code stopped working," the first question is never "what is wrong with the squares?" It is "was the payload a pointer, and is the pointer still alive?"&lt;/p&gt;

&lt;h2&gt;
  
  
  A one-minute diagnosis you can actually run
&lt;/h2&gt;

&lt;p&gt;Suppose a printed menu code now lands on an error page. Do not regenerate it yet. Work through this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scan it and read the URL the scanner shows &lt;em&gt;before&lt;/em&gt; tapping. If it is a short domain you do not control, you are in dynamic-code territory.&lt;/li&gt;
&lt;li&gt;Open that URL in a desktop browser and watch the address bar. Does it stay put, bounce through two or three hops, or land on a parking page? Each hop is another owner who can break your code.&lt;/li&gt;
&lt;li&gt;Open the final destination directly. If the final page is gone, the QR code is innocent — fix the page.&lt;/li&gt;
&lt;li&gt;If the final page loads but the short link does not, the redirect record is dead: deleted, downgraded, or orphaned when an account lapsed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That distinction decides the fix. A dead page is fixed on your site. A dead redirect is fixed in the redirect service — or not at all, if you have lost access to it. A code that scans on one phone but not another is a third thing entirely: a print problem, covered below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tracing the redirect chain in code
&lt;/h2&gt;

&lt;p&gt;If you have the short URL, you can inspect its hop chain instead of guessing. This snippet follows redirects manually and logs each step. It works best for same-origin links or hosts that allow cross-origin requests; many short-link hosts block browser fetches with CORS, in which case use your browser's Network tab or &lt;code&gt;curl -I&lt;/code&gt; on the command line instead — the idea is the same, only the tool changes.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;traceRedirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxHops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;startUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hop&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;hop&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;maxHops&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;hop&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;response&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;manual&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;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;location&lt;/span&gt;&lt;span class="dl"&gt;"&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;`Hop &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hop&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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;url&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="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;next&lt;/span&gt;&lt;span class="p"&gt;)&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Chain ends here. This is the final destination.&lt;/span&gt;&lt;span class="dl"&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;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;url&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;;&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Stopped after max hops — check for a redirect loop.&lt;/span&gt;&lt;span class="dl"&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;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Example: traceRedirect("https://short.example/abc123");&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the output like a detective. A chain that ends in a 404 means the destination moved. A chain that ends at a domain parking page means the redirect domain or record is gone. A chain that loops means someone wired two redirects to point at each other. A chain you cannot fetch from the browser at all is a reminder that the redirect hop is infrastructure you do not control — which is exactly the risk this article is about.&lt;/p&gt;

&lt;p&gt;One honest caveat about that snippet: &lt;code&gt;redirect: "manual"&lt;/code&gt; in a browser returns an opaque response for cross-origin redirects, and the &lt;code&gt;location&lt;/code&gt; header is only readable when the host permits it. Do not treat a blocked fetch as proof the link is dead. Treat it as proof you need a different tool — Network tab, &lt;code&gt;curl&lt;/code&gt;, or a server-side check — before you conclude anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The print failures that look like link failures
&lt;/h2&gt;

&lt;p&gt;Not every dead code is a dead link. Some codes fail before the network is even involved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Size.&lt;/strong&gt; A code printed too small leaves no margin for shaky hands or weak cameras. The ToolkitLoop generator suggests about 25 mm or larger for print; posters and packaging need more, because viewing distance grows with the surface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contrast and polarity.&lt;/strong&gt; Readers expect dark modules on a light background. Light-grey on white, colour-on-colour, or reversed white-on-black codes look designed and scan badly on at least some phones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logo coverage.&lt;/strong&gt; A centre logo is safe when it stays small and the error correction level is high. Error correction levels run L, M, Q, and H, from least to most redundant data. A big logo on level L eats modules the reader cannot reconstruct; the same logo on level H often survives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quiet zone and distortion.&lt;/strong&gt; The blank border around a code is part of the code. Crop it, stretch the code to fit a banner, or wrap it around a curved bottle, and even a perfect link will struggle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical test is boring and effective: print the final file, scan it on two different phones, at the real distance, in realistic light. A code that only works on your own phone in perfect light is not done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why static is the default I ship
&lt;/h2&gt;

&lt;p&gt;This is where the disclosure and the engineering meet. The generator I built at ToolkitLoop creates static codes only: the content is encoded in the image, generation happens locally in your browser, there is no redirect layer, and the downloaded file carries no watermark. You can try it here: &lt;a href="https://toolkitloop.com/qr-code-generator" rel="noopener noreferrer"&gt;https://toolkitloop.com/qr-code-generator&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That choice trades away one feature — you cannot change the destination after printing — in exchange for removing an entire class of failure. There is no redirect account to lapse, no short domain to park, and no subscription keeping your printed posters alive. What can still break is the destination itself: if you delete the menu page the code points to, the code will faithfully deliver visitors to a 404. That is a website problem with a website fix, not a QR problem.&lt;/p&gt;

&lt;p&gt;Static is not the right answer for everything. If you genuinely need to swap a campaign destination after printing, a dynamic code — ideally on a domain you control — is the honest tool for that job. But if the destination is stable — a homepage, a permanent menu, contact details, Wi-Fi credentials — paying for redirect machinery you will forget to maintain is how "free QR codes" end up with an expiry story.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist before your next print run
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Is the payload the final address, or a pointer you will have to maintain?&lt;/li&gt;
&lt;li&gt;Does the final address load, right now, in a private browser window?&lt;/li&gt;
&lt;li&gt;Is the code at least about 25 mm for handheld print, larger for distance viewing?&lt;/li&gt;
&lt;li&gt;Is the contrast dark-on-light, with the quiet zone intact and no stretching?&lt;/li&gt;
&lt;li&gt;Is any centre logo small, with error correction set high enough to cover it?&lt;/li&gt;
&lt;li&gt;Have two different phones scanned the final file at the real distance?&lt;/li&gt;
&lt;li&gt;Did you download SVG for print, reserving PNG for tools that specifically need a raster file?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can answer yes to all of those, the code you print is about as durable as a printed link can be. If you cannot, the failing item on that list is your fix — and it is almost never "make the squares prettier."&lt;/p&gt;

&lt;p&gt;Now the hook I promised: how do you trace a suspicious redirect chain in your own stack — browser Network tab, &lt;code&gt;curl -I&lt;/code&gt;, or a server-side check? And have you ever found a chain with a hop you did not know existed? I am curious which tool caught it, because that hidden hop is usually the one that breaks first.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>beginners</category>
    </item>
    <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>
  </channel>
</rss>
