<?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 Volume Converter That Knows a Cup Isn't a Cup</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Sun, 15 Nov 2026 03:06:03 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-volume-converter-that-knows-a-cup-isnt-a-cup-3o70</link>
      <guid>https://dev.to/bowentse/i-built-a-volume-converter-that-knows-a-cup-isnt-a-cup-3o70</guid>
      <description>&lt;p&gt;I built a volume converter. The interesting part is not the math — it is one line long. The interesting part is that a cup is not a cup.&lt;/p&gt;

&lt;p&gt;A recipe said 1 cup. My converter said 236.59 mL. The recipe meant 250 mL. That is a 13.41 mL gap per cup, about 5.7% — invisible in a soup, real in a cake. The bug was not in the arithmetic. It was in the assumption that "cup" names one volume. It does not. So the converter I built treats units as locale-dependent labels, not as fixed numbers. Here is how the pieces fit together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every unit converts through one base
&lt;/h2&gt;

&lt;p&gt;The cleanest design is a table of factors against a single base unit, and for volume that base is the milliliter. Add a unit and you add one line — no conversion matrix, no special cases that drift out of sync.&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="no"&gt;ML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;ml&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="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;// exact, by definition&lt;/span&gt;
  &lt;span class="n"&gt;usCup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;236.5882365&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// US customary cup&lt;/span&gt;
  &lt;span class="n"&gt;metricCup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// metric cup — a different unit&lt;/span&gt;
  &lt;span class="n"&gt;usTbsp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;14.7867648&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// exactly 3 US teaspoons&lt;/span&gt;
  &lt;span class="n"&gt;usTsp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.92892159&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;usFlOz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;29.5735296&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;impFlOz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;28.4130625&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// smaller than the US fl oz&lt;/span&gt;
  &lt;span class="n"&gt;usPint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;473.176473&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// 16 US fl oz&lt;/span&gt;
  &lt;span class="n"&gt;impPint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;568.26125&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// 20 Imperial fl oz&lt;/span&gt;
  &lt;span class="n"&gt;usQuart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;946.352946&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// 4 US cups&lt;/span&gt;
  &lt;span class="n"&gt;usGal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3785.411784&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// US gallon&lt;/span&gt;
  &lt;span class="n"&gt;impGal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4546.09&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// Imperial gallon — ~20% larger&lt;/span&gt;
  &lt;span class="n"&gt;m3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;// 1 cubic meter = 1,000 liters&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;ML&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="no"&gt;ML&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;to&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;Two facts in that table surprise most people. First, the Imperial fluid ounce is &lt;em&gt;smaller&lt;/em&gt; than the US fluid ounce (28.41 mL vs 29.57 mL), even though the Imperial gallon is &lt;em&gt;larger&lt;/em&gt; than the US gallon (4.546 L vs 3.785 L). The two systems do not scale together, so any converter that treats "gallon" and "fluid ounce" as one family is wrong in both directions. Second, the metric side is exact by definition — 1 liter is 1,000 mL, 1 mL is 1 cubic centimeter — while the customary side is a pile of historical accidents with long decimal tails.&lt;/p&gt;

&lt;p&gt;That is lesson one: &lt;strong&gt;the conversion factor is a definition, not a measurement.&lt;/strong&gt; There is no sensor noise here. A converter has no excuse for being approximately right.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Cup" needs a locale, not a number
&lt;/h2&gt;

&lt;p&gt;The real design question is what the converter does when a user types "cup" and nothing else. Three options exist, and each is defensible:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Default to the US customary cup (236.59 mL), because most English-language recipes are American.&lt;/li&gt;
&lt;li&gt;Default to the metric cup (250 mL), because it is the rounder number and the international standard.&lt;/li&gt;
&lt;li&gt;Refuse to guess, and ask which cup the recipe means.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My converter defaults to US customary — the tool's audience is mostly US recipes — but labels the unit explicitly as "US cup" so the assumption is visible, not silent. A silent default is how a cake ends up 5.7% short on liquid per cup. The same logic applies to gallons: the tool never shows a bare "gallon". It shows "US gallon" or "Imperial gallon", because a bare gallon is a coin flip worth 0.76 liters.&lt;/p&gt;

&lt;p&gt;The same trap hides in tablespoons. A US tablespoon is 14.79 mL. An Australian tablespoon is 20 mL. Four Australian tablespoons in a small sauce is a quarter-cup error — enough to notice. If your converter hides which tablespoon it means, it is not converting. It is guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Display rounding is a display decision
&lt;/h2&gt;

&lt;p&gt;Convert 1 liter to US cups and back: 4.22675 cups, rounded to 4.23, back to 1.0008 liters. You just invented 0.8 mL. Do that across a recipe scaled four ways and the drift becomes real error. So the converter stores full precision and rounds only for display, with a precision selector the user controls:&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;display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// The stored value never changes; only the digits on screen do.&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="n"&gt;value&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="n"&gt;precision&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;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;cups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;convert&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;"l"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"usCup"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// 4.2267528377...&lt;/span&gt;
&lt;span class="nf"&gt;display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cups&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="p"&gt;;&lt;/span&gt;                            &lt;span class="c1"&gt;// 4.23 on screen&lt;/span&gt;
&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cups&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="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"usCup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"l"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// 1.0008 — display loss, not storage loss&lt;/span&gt;
&lt;span class="nf"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"usCup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"l"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                &lt;span class="c1"&gt;// 1.0 — full precision round-trips cleanly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Round-trip error is the test I run on every unit pair: convert there and back at full precision, and the result must equal the input. If it does not, the bug is in the table, not in the rounding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one conversion a volume tool must refuse
&lt;/h2&gt;

&lt;p&gt;Volume cannot become grams. A cup is a volume; a gram is a mass. The same cup holds a different weight of flour than of sugar, so the conversion needs the ingredient's density — information the number alone does not carry. My converter says so on the page instead of inventing an answer:&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;cupsToGrams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ingredient&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="n"&gt;ingredient&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ingredient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;density&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Volume needs a density to become mass."&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cups&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;236.5882365&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ingredient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;density&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// mL * g/mL&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commonly used approximations — 1 cup of all-purpose flour ≈ 120 g, sugar ≈ 200 g, butter ≈ 227 g — are averages, not definitions. A converter that bakes one of them in silently is not being helpful. It is hiding a guess behind a decimal point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The debatable part
&lt;/h2&gt;

&lt;p&gt;So here is the question I keep coming back to: when a recipe says "1 cup" and nothing else, should a converter default to the US cup, default to the metric cup, or refuse to guess and ask? I defaulted to US customary with an explicit label. A reasonable person could argue the metric cup is the better default — it is the international standard, and 250 is a rounder number. Another could argue that any silent default is a bug, and the tool should force the choice.&lt;/p&gt;

&lt;p&gt;Which would you pick — and what is the worst "wrong cup" story you have? I built this converter, and I am still not sure the default is right. You can try it free, no sign-up, entirely in your browser: &lt;a href="https://toolkitloop.com/volume-converter" rel="noopener noreferrer"&gt;Volume Converter&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Weight Converter That Never Rounds Your Data</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Fri, 13 Nov 2026 03:04:14 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-weight-converter-that-never-rounds-your-data-2dkm</link>
      <guid>https://dev.to/bowentse/i-built-a-weight-converter-that-never-rounds-your-data-2dkm</guid>
      <description>&lt;p&gt;I built a weight converter. The interesting part is not the math — it is one line long. The interesting part is where everyone puts the rounding.&lt;/p&gt;

&lt;p&gt;Convert 70 kg to pounds, round to 154 lb, convert back: 69.85 kg. You just lost 0.15 kg to a round trip that never needed to happen. Do that across a spreadsheet of body weights or shipping weights and the drift becomes real error. So the converter I built stores full precision and rounds only for display. Here is why that is the right call, and how the pieces fit together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The factor is a definition, not a measurement
&lt;/h2&gt;

&lt;p&gt;One pound is defined as exactly 0.45359237 kilograms. That makes the reverse factor exact too:&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="no"&gt;KG_PER_LB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.45359237&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// exact, by definition&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;LB_PER_KG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="no"&gt;KG_PER_LB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// 2.2046226218...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no sensor noise here, no calibration drift. Kilograms and pounds are tied by fixed definitions, so a converter has no excuse for being approximately right. "1 kg = 2.2 lb" is a display choice, not a fact about the world — and the moment a tool treats the display value as the stored value, it starts inventing error.&lt;/p&gt;

&lt;p&gt;That is lesson one: &lt;strong&gt;keep the full-precision number internally, and let the user pick the display precision.&lt;/strong&gt; My converter has a precision selector for exactly this reason. The stored value never changes; only the digits on screen do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stones, pounds, and ounces are distinct units
&lt;/h2&gt;

&lt;p&gt;The second trap is treating weight units as one sliding scale. They are not. A stone is 14 pounds, and 14 pounds is 6.35029 kg — but a stone is still its own unit, and people write body weight as "11 stone 4 lb", not as a decimal.&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="no"&gt;UNITS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;kg&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="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.45359237&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;oz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.028349523125&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;6.35029318&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// 14 lb, exact&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;Every unit converts through kilograms as the base. Add a unit and you add one line — no special cases, no conversion matrix that drifts out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parsing "7 lb 4 oz" the way people write it
&lt;/h2&gt;

&lt;p&gt;Nobody says "7.25 pounds" out loud. They say "seven pounds four". So the converter's smart input reads compound values directly:&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;parseCompound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "7 lb 4 oz" -&amp;gt; kilograms&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;parts&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="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&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="p"&gt;[&lt;/span&gt;\&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&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="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kg&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;lb&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;oz&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;st&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;\&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;gi&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;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;kg&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;kg&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nb"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;UNITS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;unit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;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="n"&gt;kg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 7 lb 4 oz -&amp;gt; 3.289 kg&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. First, each part converts through the exact factor before anything is added — never round the parts and then sum them. Second, the parser is deliberately strict about unit tokens: "7 lb 4 oz" parses, "7 pounds 4 ounces" should too in a real product, but free-text words like "about" must not silently become zero.&lt;/p&gt;

&lt;p&gt;The same input box also accepts cross-category phrases like "10 km to miles" or "5'8\" to cm" — the converter family shares one smart-input parser, and each tool only answers for its own units.&lt;/p&gt;

&lt;h2&gt;
  
  
  Display rounding is a UI decision, not a math step
&lt;/h2&gt;

&lt;p&gt;Here is the rule I settled on, and the one I would defend in review:&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;display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valueKg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toUnit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decimals&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="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;valueKg&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="no"&gt;UNITS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;toUnit&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// full precision, always&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;raw&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="n"&gt;decimals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// rounding happens here, once&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Round once, at the edge, for human eyes. Never round an intermediate value that another calculation will consume. If the user picks 2 decimals, 70 kg shows as 154.32 lb — and converting back still starts from 70 kg, not from 154.32.&lt;/p&gt;

&lt;p&gt;This is also why "2.2" as a stored factor is a bug, not a simplification. Store 2.2046226218, display 2.20. The cost is nothing; the correctness is free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The baking caveat no converter can fix
&lt;/h2&gt;

&lt;p&gt;One thing the code cannot do: convert cups to grams. A cup is a volume, a gram is a mass, and the bridge between them is the ingredient's density. The converter's own FAQ says this plainly — cups-to-grams depends on the ingredient. Commonly used approximations are 1 cup of all-purpose flour ≈ 120 g, 1 cup of granulated sugar ≈ 200 g, and 1 cup of butter ≈ 227 g, but those are averages, not definitions.&lt;/p&gt;

&lt;p&gt;A converter that quietly bakes in one density and presents the result as exact is worse than one that refuses. If your tool offers cups, label the density, let the user change it, and say the result is approximate. Honest imprecision beats confident error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it runs entirely in the browser
&lt;/h2&gt;

&lt;p&gt;The converter is part of ToolkitLoop, a set of free tools I built that run entirely in your browser — no sign-up, no uploads, nothing stored on a server. Weight numbers are often health data or business data, and a browser-local tool has nothing to leak because there is nothing to send.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://toolkitloop.com/weight-converter" rel="noopener noreferrer"&gt;Weight Converter&lt;/a&gt;. Type "7 lb 4 oz" and watch it come back as 3.29 kg. Then change the precision selector and notice the stored value never moves — only the digits on screen do.&lt;/p&gt;

&lt;p&gt;Here is the question I keep coming back to: should a converter ever round its stored value, or is display-only rounding the only honest default? And has a round-trip conversion ever bitten you — a recipe, a shipping label, a gym log? I am genuinely curious where the 0.15 kg went in your workflow.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Length Converter That Rounds Honestly: 5 Lessons</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Wed, 11 Nov 2026 02:56:17 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-length-converter-that-rounds-honestly-5-lessons-lej</link>
      <guid>https://dev.to/bowentse/i-built-a-length-converter-that-rounds-honestly-5-lessons-lej</guid>
      <description>&lt;p&gt;I once watched two people argue over whether a shelf was 3.94 inches or 3.937 inches wide. Both were reading the same 10 cm measurement. One of them was holding a tape measure; the other was holding a calculator with too many decimal places. That argument is why I built a length converter — and why it rounds its answers on purpose.&lt;/p&gt;

&lt;p&gt;Disclosure first: I built this tool. The Length Converter at ToolkitLoop is my free browser-side tool, and this post is the build log for it: &lt;a href="https://toolkitloop.com/length-converter" rel="noopener noreferrer"&gt;https://toolkitloop.com/length-converter&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: One exact definition anchors everything
&lt;/h2&gt;

&lt;p&gt;Length conversion looks like a table of magic numbers until you find the one definition everything hangs from. Since the international yard and pound agreement of 1959, one inch is defined as exactly 2.54 centimeters. Not measured — defined. That single sentence pins down the foot (exactly 30.48 cm), the yard (exactly 0.9144 m), and every conversion between metric and imperial length.&lt;/p&gt;

&lt;p&gt;So the converter does not store a table of factors. It stores one base unit and derives the rest:&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;// One exact anchor: 1 inch = 2.54 cm, by definition since 1959.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;METERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;mm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;cm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&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="n"&gt;km&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0254&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// exact&lt;/span&gt;
  &lt;span class="n"&gt;ft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3048&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// exact: 12 * 0.0254&lt;/span&gt;
  &lt;span class="n"&gt;yd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9144&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// exact: 36 * 0.0254&lt;/span&gt;
  &lt;span class="n"&gt;mi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1609.344&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// exact: 1760 * 0.9144&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convertLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;METERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="no"&gt;METERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;to&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;span class="nf"&gt;convertLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"cm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 3.937007874015748&lt;/span&gt;
&lt;span class="nf"&gt;convertLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"km"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"mi"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 6.2137119223733395&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw result of 10 cm is 3.937007874015748 inches. That is mathematically right and practically useless — which brings us to lesson two.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: Round for display, never for the next step
&lt;/h2&gt;

&lt;p&gt;The tool shows 3.94 inches, not 3.937007874015748. That is a deliberate choice, and it is the one I would defend hardest. Rounding the display is honesty: no tape measure resolves the seventh decimal place, and showing fifteen digits pretends at a precision the input never had.&lt;/p&gt;

&lt;p&gt;But rounding must happen at the very last moment. If you round to two decimals after every step of a chained calculation, the error compounds — round a dozen boards to the nearest centimeter each and the total can drift by several centimeters. The rule in the code is simple: keep the full float internally, format 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="c1"&gt;// Keep full precision through the math; round only for the eye.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;precision&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;factor&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="n"&gt;precision&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;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="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;convertLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"cm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"in"&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;span class="c1"&gt;// 3.94 — shown&lt;/span&gt;
&lt;span class="nf"&gt;convertLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"cm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// 3.937007874015748 — kept&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is also why the tool exposes a precision control instead of hard-coding two decimals. Cutting fabric wants two; a road trip wants one; machining wants all of them until the final cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: Compound input is a parser, not a text field
&lt;/h2&gt;

&lt;p&gt;The most common length input in the real world is not a number — it is a height. People write 5′8″, 5 ft 8 in, or 5 feet 8 inches. And the classic mistake is typing 5.8, which is 5 feet 9.6 inches — a different person.&lt;/p&gt;

&lt;p&gt;So the Smart Input is a small parser. It reads a phrase like "10 km to miles" or "5′8″ to cm", splits it into value, from-unit, and to-unit, and handles compound values as two parts that get converted separately and added:&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;// "5′8″ to cm" -&amp;gt; 5 ft (152.4 cm) + 8 in (20.32 cm) = 172.72 cm&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseHeight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&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="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input&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="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;\&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;\&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt;&lt;span class="n"&gt;ft&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="s1"&gt;')\s*(\d+)\s*(?:in|"|″)?/);
  if (!match) return null;
  const [, feet, inches] = match;
  return convertLength(Number(feet), "ft", "cm")
       + convertLength(Number(inches), "in", "cm");
}

parseHeight("5′8″"); // 172.72
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson generalizes: whenever users paste real-world notation into your tool, you are building a parser whether you admit it or not. Writing the regex on purpose beats discovering it by accident in your bug reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: Precision is a UX choice, not a math choice
&lt;/h2&gt;

&lt;p&gt;Developers love to argue about float precision. Users argue about whether the answer looks right. Both are correct, and they are arguing about different layers.&lt;/p&gt;

&lt;p&gt;The converter's precision control exists because the right number of decimals depends on the job, not on the math: two decimals for fabric, whole numbers for furniture, one decimal for a run. There is no single "correct" precision, only a correct default (two decimals) and an honest way to change it. If your tool forces one precision on every use case, it is not being rigorous — it is refusing to decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: No backend means no excuses
&lt;/h2&gt;

&lt;p&gt;The whole thing runs in the browser. No account, no upload, no server round-trip. That is partly a privacy decision — a measurement you type should not become a row in somebody's database — and partly an engineering one: conversion is arithmetic, and arithmetic does not need a backend.&lt;/p&gt;

&lt;p&gt;It also removes an entire class of excuses. There is no "the server was slow" and no "the API changed the factor." The definitions are fixed, the code is small, and if the answer is wrong, the bug is in the handful of lines above, not in a pipeline.&lt;/p&gt;

&lt;p&gt;Try it: &lt;a href="https://toolkitloop.com/length-converter" rel="noopener noreferrer"&gt;https://toolkitloop.com/length-converter&lt;/a&gt; — type "10 km to miles" or "5′8″ to cm" and watch the answer update as you type. No convert button, no signup.&lt;/p&gt;

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

&lt;p&gt;Here is the part I genuinely do not have a settled answer for, and I would like to hear yours: when a converter shows you 3.94 instead of 3.937007874015748, is that honesty or is it hiding information? My take is that rounding for display is honest because the input never had fifteen digits of precision. But I have also heard the opposite argument — that a tool should show everything it knows and let the user decide what to ignore.&lt;/p&gt;

&lt;p&gt;Where do you draw the line — and does it move when the use case changes from cutting fabric to machining a part? Drop your rule in the comments. The best answer I have seen so far is "match the precision to the tool you will measure with," and I am curious whether that survives contact with your workflow.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I built this tool — the Length Converter at ToolkitLoop is mine, free, and runs entirely in your browser.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Zero-Backend Currency Converter: 5 Travel Lessons</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Mon, 09 Nov 2026 02:53:25 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-zero-backend-currency-converter-5-travel-lessons-eh2</link>
      <guid>https://dev.to/bowentse/i-built-a-zero-backend-currency-converter-5-travel-lessons-eh2</guid>
      <description>&lt;p&gt;A currency converter looks like the simplest tool on the internet: multiply an amount by a rate, show the answer. I built one anyway — a zero-backend converter at &lt;a href="https://toolkitloop.com/currency-converter" rel="noopener noreferrer"&gt;ToolkitLoop&lt;/a&gt; — and the interesting problems turned out to have nothing to do with multiplication. They were about which rate you show, how old it is allowed to be, and what your users think the number means.&lt;/p&gt;

&lt;p&gt;Disclosure up front: I built this tool. It is free, runs in the browser, and needs no signup. Everything below comes from decisions I actually made while building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: A rate is two timestamps, not one
&lt;/h2&gt;

&lt;p&gt;The first design decision was what to display next to the number. A rate without a date is a claim without evidence, so the page shows two separate timestamps: the rate date (when the provider published the rate) and the fetch time (when this site retrieved it). They answer different questions. "Published yesterday, fetched a minute ago" is not the same as "published this morning, fetched a minute ago," and a traveller checking today's cash withdrawal needs to know which one they are looking at.&lt;/p&gt;

&lt;p&gt;The data comes from Frankfurter v2, which aggregates institutional reference data, requested through a server-side cache. The default cache is one hour. If the provider is temporarily unavailable, an older usable cache is explicitly labelled as old rather than presented as new data. That labelling rule was a deliberate choice: a converter that silently serves a stale rate is worse than one that says "this is old" and lets the user decide.&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;// The shape of the decision, not the production code:&lt;/span&gt;
&lt;span class="c1"&gt;// a rate is only trustworthy if you can answer both questions.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// example only — never a live rate&lt;/span&gt;
  &lt;span class="na"&gt;rateDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-11-09&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// when the provider published it&lt;/span&gt;
  &lt;span class="na"&gt;fetchedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-11-09T10:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// when we retrieved it&lt;/span&gt;
  &lt;span class="na"&gt;cacheAgeMinutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;isStale&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="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isUsable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxAgeMinutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&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;rate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isStale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;usable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;labelled stale cache&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;usable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cacheAgeMinutes&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxAgeMinutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;within cache window&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;The debatable point, and the one I would argue about over coffee: is a labelled one-hour-old rate more honest than a silently refreshed one? I chose labelling. A rate that admits its age lets a careful user re-check; a rate that hides it invites a false sense of precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: Your amount should never leave the browser
&lt;/h2&gt;

&lt;p&gt;The architecture is the privacy story. The amount you type stays in your browser; only the selected currency pair is requested to retrieve the reference rate. The server receives currency codes, never the raw amount. There is no account, no history, no "your recent conversions" feature — because there is nothing to store.&lt;/p&gt;

&lt;p&gt;That constraint shaped the implementation more than any framework choice. The formula is trivial — amount × reference rate = converted result — and the interesting code is the boundary: what crosses the network (two currency codes) and what never does (everything else). Swapping the currencies recalculates immediately from the loaded reference rate, with no new request for the amount, because the amount was never sent in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: The reference rate is not the settlement price
&lt;/h2&gt;

&lt;p&gt;This is the lesson most users get wrong, and the one the tool's own FAQ has to keep repeating: a reference rate is useful for comparison, but it is not your bank, card-network, or ATM settlement price. Your charge can differ because of issuer rates, markups, foreign-transaction fees, fixed fees, and timing.&lt;/p&gt;

&lt;p&gt;I deliberately did not bake a fee percentage into the tool. Card and ATM fees vary by product, location, and transaction, so assuming a fixed percentage would manufacture a false precision. A future fee mode will let you enter your own assumptions and clearly distinguish that estimate from the reference rate. Until then, the honest workflow is two numbers: the reference result for planning, and your card's terms for the final cost. If a converter shows you one number and calls it "what you will pay," it is lying by omission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: DCC is a UX problem, not just a fee problem
&lt;/h2&gt;

&lt;p&gt;Dynamic currency conversion (DCC) is the option that lets a merchant or ATM charge you in your home currency. Its exchange rate and fees can be less favourable than paying in the local currency. Every traveller guide says "always pay in local currency," and yet people still accept DCC — because the terminal frames it as helpful. You see the amount in dollars. It feels transparent. The markup is invisible.&lt;/p&gt;

&lt;p&gt;That is a design problem as much as a finance problem. The terminal optimises for the feeling of certainty ("you know exactly what you pay") over the reality of cost. Building a converter taught me to respect that framing effect: my own tool has to fight the same instinct. Showing a single converted number feels certain; showing the rate date, the cache status, and a warning that your card may differ feels fussy. I kept the fussy version, because the certain version is the one that misleads.&lt;/p&gt;

&lt;p&gt;Practically: at a card terminal, choose the local currency if offered. At an ATM, decline the conversion offer — declining it does not cancel the withdrawal, it just lets your bank do the conversion. At a hotel desk, check the bill's currency before you sign, because a bill converted at check-in locks in the merchant's rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: Say what the tool does not do yet
&lt;/h2&gt;

&lt;p&gt;The first release focuses on a transparent current/reference conversion. Historical-date and trend analysis will be added to the same tool rather than as empty or duplicate pages, and there is no fee mode yet. Writing those limits on the page felt wrong at first — why advertise what is missing? — but it is the same honesty as labelling a stale cache. A user planning an expense report around last month's rate needs to know this tool cannot give it to them, before they build a spreadsheet on it. Use your card statement's settled rate for that, not today's reference rate.&lt;/p&gt;

&lt;p&gt;The popular pairs tell the same story about scope: USD to EUR, USD to JPY, EUR to USD, GBP to USD. Travellers check a small set of pairs obsessively and the rest rarely. Depth on the pairs people actually use beats a long list nobody opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I keep coming back to
&lt;/h2&gt;

&lt;p&gt;Here is the hook I promised: should a converter round to the nearest cent, or keep full precision? Rounding feels friendly and matches what a bank statement shows. Full precision is more honest about the maths, and the rounding error compounds when you convert a budget line by line. I rounded the displayed result and kept the unrounded value for the calculation — but I am not sure that is right. If you convert a 14-line trip budget, do you want each line rounded, or the total rounded once at the end?&lt;/p&gt;

&lt;p&gt;Tell me how you would build it — and what your worst DCC or settlement-day surprise was. The comments on my last few posts turned into the best code reviews I have had, and this one has a genuinely open question in it.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Zero-Backend Unit Converter: 5 Lessons for Devs</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Sat, 07 Nov 2026 02:55:27 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-zero-backend-unit-converter-5-lessons-for-devs-4le8</link>
      <guid>https://dev.to/bowentse/i-built-a-zero-backend-unit-converter-5-lessons-for-devs-4le8</guid>
      <description>&lt;p&gt;A unit converter looks like the easiest tool on the internet. It is a lookup table, right? Multiply by a constant, show the answer, done.&lt;/p&gt;

&lt;p&gt;Then you build one, and it fights back. I built this one — the ToolkitLoop Unit Converter (&lt;a href="https://toolkitloop.com/unit-converter" rel="noopener noreferrer"&gt;https://toolkitloop.com/unit-converter&lt;/a&gt;) — as a zero-backend, client-side tool: no ads, no signup, results update as you type, and fixed-unit conversions stay in the browser. Here are the five lessons that surprised me most.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The input is a parser, not a text field
&lt;/h2&gt;

&lt;p&gt;The tool page invites you to type "10 km to miles", "5′8″ to cm", or "7 lb 4 oz to kg". That single input box is doing real parsing work.&lt;/p&gt;

&lt;p&gt;A height like 5′8″ is two numbers in two units. A weight like 7 lb 4 oz is the same. A naive implementation that grabs the first number and the first unit it sees will silently convert only the feet, or only the pounds, and return an answer that looks confident and is wrong.&lt;/p&gt;

&lt;p&gt;The shape of the problem is a tiny grammar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Illustrative: the shape of a compound-input parser&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseCompound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "5'8"" -&amp;gt; [{ value: 5, unit: "ft" }, { value: 8, unit: "in" }]&lt;/span&gt;
  &lt;span class="c1"&gt;// "7 lb 4 oz" -&amp;gt; [{ value: 7, unit: "lb" }, { value: 4, unit: "oz" }]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&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;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&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;(?:\.\d&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;a-z'"&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;/gi&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;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;_&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="nx"&gt;unit&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="na"&gt;value&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;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;unit&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson: the moment your input accepts human phrasing, you own a parser, with all the edge cases that implies. What counts as a unit token? Is &lt;code&gt;'&lt;/code&gt; feet or minutes? Does &lt;code&gt;5'8"&lt;/code&gt; mean the same as &lt;code&gt;5 ft 8 in&lt;/code&gt;? Every "yes" you add to that list is a test case you now have to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Temperature is not a ratio — it is an affine transform
&lt;/h2&gt;

&lt;p&gt;Most conversions are a multiplication: value × factor. Temperature breaks that pattern. Celsius to Fahrenheit is multiply by 9/5, then add 32. Fahrenheit to Celsius is subtract 32, then multiply by 5/9. Kelvin shifts everything by 273.15.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Illustrative: affine vs ratio conversions&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convert&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="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Ratio conversions: { factor }&lt;/span&gt;
  &lt;span class="c1"&gt;// Affine conversions: { scale, offset } — apply scale, then offset&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toBase&lt;/span&gt; &lt;span class="o"&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="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scale&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;toBase&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;.&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;to&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offset&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;If your conversion table stores only a factor per unit, temperature will be wrong everywhere except at the single point where the two scales happen to cross. The fix is to store a scale and an offset for every unit and treat ratio conversions as the special case where the offset is zero. That one design decision — always carry both numbers — removes an entire class of bug.&lt;/p&gt;

&lt;p&gt;It also explains why a generic "convert anything" function that only multiplies is a trap. The moment someone adds a temperature unit to it, every temperature answer goes wrong, quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. US vs Imperial is a labelling problem, not a maths problem
&lt;/h2&gt;

&lt;p&gt;The tool labels US and Imperial units separately, and that label is doing more work than the arithmetic. A US gallon and an Imperial gallon are different volumes. Same name, different size.&lt;/p&gt;

&lt;p&gt;This is a data-modelling lesson: the unit's identity is not its name, it is its name plus its system. If your internal key is just &lt;code&gt;"gallon"&lt;/code&gt;, you have already lost the distinction, and no amount of correct arithmetic downstream can recover it. The key has to be something like &lt;code&gt;gallon_us&lt;/code&gt; vs &lt;code&gt;gallon_imperial&lt;/code&gt;, and the UI has to show the system to the user — otherwise the user cannot tell which one they picked, and a wrong-by-20% answer looks perfectly plausible.&lt;/p&gt;

&lt;p&gt;The same pattern shows up everywhere in software: two things with the same display name and different meanings, collapsed into one key. The fix is always the same — make the key carry the distinction, and make the UI show it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Precision is a UX decision, not a formatting one
&lt;/h2&gt;

&lt;p&gt;The converter lets you set precision, and that choice matters more than it looks. Show too few decimals and the answer is useless for the task. Show too many and you are lying: a long decimal implies an exactness the input never had.&lt;/p&gt;

&lt;p&gt;A good rule of thumb I landed on: precision should match the task, not the calculation. Travel distances need one or two decimals. Cooking needs whole numbers or one decimal. And if the input was approximate — "about 10 km" — the answer is approximate too, however many digits you print.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Illustrative: round for display, never for the next step&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;display&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="nx"&gt;decimals&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;factor&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="k"&gt;return&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;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Keep the full-precision value internally; round only at the edge, and let the user choose how many decimals they see.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rounding is a display concern. The moment you round the stored value, every chained conversion inherits the error. Keep full precision internally, round only at the edge, and let the user choose how many decimals they see.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. "Instant" is a contract with the input loop
&lt;/h2&gt;

&lt;p&gt;The tool has no convert button: results update as you type or change units. That sounds like a small detail. It changes the architecture.&lt;/p&gt;

&lt;p&gt;With a button, conversion runs once, on demand. Without one, it runs on every keystroke, which means it has to be cheap, synchronous, and total — it must return something sensible for every intermediate state, including a half-typed number, an empty field, and a unit pair that does not make sense together.&lt;/p&gt;

&lt;p&gt;That constraint pushes you toward a design where conversion is a pure function of (value, from, to): no network, no async, no state beyond the three inputs. Which is exactly why the tool can be zero-backend and keep everything in the browser. The UX requirement (instant) and the architecture (client-side only, no signup, no ads) are the same decision seen from two sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would argue about
&lt;/h2&gt;

&lt;p&gt;Here is the part I am least sure about, and the question I want to leave open: what is the hardest part of a unit converter — the parsing, the affine maths, or the labelling?&lt;/p&gt;

&lt;p&gt;My instinct after building it is the labelling. The maths is a solved problem once you carry scale plus offset. The parsing is bounded — there are only so many ways people write a height. But the labelling problem never ends, because every new unit system, every regional variant, and every same-name-different-size pair is another distinction the UI has to carry without confusing the person who just wants to know how many miles 10 km is.&lt;/p&gt;

&lt;p&gt;If you have built a converter, a calculator, or anything that takes human phrasing as input: what broke first for you — the parser, the maths, or the labels? And is there a unit pair you still do not trust your own tool with?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I built this tool. The Unit Converter (&lt;a href="https://toolkitloop.com/unit-converter" rel="noopener noreferrer"&gt;https://toolkitloop.com/unit-converter&lt;/a&gt;) is part of ToolkitLoop, a set of free browser-side tools — no ads, no signup, and conversions happen in your browser.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a No-Signup Calorie Calculator: 5 TDEE Lessons</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Thu, 05 Nov 2026 02:48:41 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-no-signup-calorie-calculator-5-tdee-lessons-157i</link>
      <guid>https://dev.to/bowentse/i-built-a-no-signup-calorie-calculator-5-tdee-lessons-157i</guid>
      <description>&lt;p&gt;Most calorie calculators ask for your stats, show a confident number, and hide how they got it. I built one that does the opposite: it names its equation, shows its activity factor range, rounds to 10 kcal, and puts maintenance first. Disclosure up front: I built ToolkitLoop, and this calculator is one of its free browser tools. The code and formulas below are the interesting part, not the promotion.&lt;/p&gt;

&lt;p&gt;Try it here: &lt;a href="https://toolkitloop.com/calorie-calculator" rel="noopener noreferrer"&gt;https://toolkitloop.com/calorie-calculator&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: Name the equation or the number means nothing
&lt;/h2&gt;

&lt;p&gt;The default equation is Mifflin-St Jeor. The tool also offers revised Harris-Benedict and, when body fat percentage is known, Katch-McArdle. That menu exists because switching equations moves the answer, and a user who cannot see which equation ran cannot reproduce the result.&lt;/p&gt;

&lt;p&gt;In code, the Mifflin-St Jeor branch is deliberately boring:&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;bmrMifflinStJeor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sex&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;weightKg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;heightCm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;age&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;base&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;weightKg&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;6.25&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;heightCm&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;age&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;sex&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;male&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;base&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="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;161&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;Boring is the point. A calorie estimate should be a transparent formula with visible inputs, not a proprietary model. If a health-adjacent tool will not show its math, treat its output as marketing copy with a number attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: Activity factor is the biggest source of error
&lt;/h2&gt;

&lt;p&gt;TDEE is BMR multiplied by an activity factor, here from 1.2 to 1.9:&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;tdee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bmr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;activityFactor&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;bmr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;activityFactor&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;One line, huge consequences. Moving from 1.4 to 1.6 on a 1,600 kcal BMR adds 320 kcal, more than most equation switches. And users pick this factor badly, usually from gym days alone, while the honest inputs include occupation, steps, standing time, and training duration and intensity.&lt;/p&gt;

&lt;p&gt;That is why the tool has an Activity Level Assistant: it suggests a band, shows an adjacent comparison, and labels confidence as low or medium, and the user can override it. The lesson for any developer building an estimator: when one input dominates the output, spend your UX budget there, not on a prettier result card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: Round aggressively and say so
&lt;/h2&gt;

&lt;p&gt;The tool rounds results to 10 kcal and presents a starting range. Early versions of estimators like this often print something like 2,347 kcal, which implies a measurement device that does not exist. Rounding is a user-interface decision about honesty:&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;roundTo10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kcal&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="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;kcal&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="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rounded range invites testing: eat near the middle for two weeks, watch the trend, adjust. A fake-precise number invites arguing with the tool when the scale disagrees by 100 kcal, which it will, because equations are estimates and bodies are not spreadsheets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: Caps are guardrails, not targets
&lt;/h2&gt;

&lt;p&gt;Loss scenarios are capped at 20 percent or 500 kcal per day, whichever is smaller; gain is capped at 15 percent:&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;lossScenario&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maintenance&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;cut&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maintenance&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&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;maintenance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;cut&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;Writing the cap as &lt;code&gt;Math.min&lt;/code&gt; matters. A percentage-only cap lets a large maintenance number justify a huge deficit; a fixed-only cap lets a small maintenance number justify a reckless percentage. Taking the smaller of the two keeps both failure modes in check. The same pattern shows up in rate limiters and budgets: when two limits protect different cases, apply both and take the stricter one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: State who the tool is NOT for
&lt;/h2&gt;

&lt;p&gt;The calculator is explicit: it is an estimate, not medical or nutrition advice, and it is not for minors, pregnancy or breastfeeding, medical nutrition therapy, eating-disorder treatment, or specialised endurance or competitive nutrition. Its supported self-guided range starts at 1,200 kcal, described as a product range, not a universal clinical safety line.&lt;/p&gt;

&lt;p&gt;That paragraph is the most important feature in the whole tool, and it costs nothing to implement. Many developer-built health tools fail here: they ship the formula, skip the scope, and let users apply an estimator to cases it was never designed for. If you build anything health-adjacent, write the exclusion list before you write the formula.&lt;/p&gt;

&lt;h2&gt;
  
  
  Macros: anchor protein, then fill the rest
&lt;/h2&gt;

&lt;p&gt;Standard mode splits calories by percentage: protein 4 kcal per gram, carbohydrate 4 kcal per gram, fat 9 kcal per gram, and a custom split must total 100 percent. The optional active mode sets protein by body weight first, then allocates fat and the remaining carbohydrate. Anchoring one macro before splitting the rest avoids the classic bug where percentages add up to 110 or 90 and nobody notices until the grams look wrong.&lt;/p&gt;

&lt;p&gt;Everything runs in the browser with no signup, and entered values stay on the device. For a tool that asks for body measurements, that architecture is not a bonus feature; it is the minimum respectful design.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I keep coming back to
&lt;/h2&gt;

&lt;p&gt;Here is the hook I would actually argue about: should a calorie calculator ever show a single number, or should it always show a range with its equation and activity assumption attached? A single number is easier to act on. A range with its assumptions is easier to trust and easier to correct. I chose the range, and I think the single-number design is why people calculator-hop until they find the answer they wanted.&lt;/p&gt;

&lt;p&gt;What would change your mind: a tighter equation, a better activity input, or just a number that stops pretending to be exact? Tell me which one you would build first, and why.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Built a Client-Side WebP to PNG Converter: 5 Lessons</title>
      <dc:creator>bowen Tse</dc:creator>
      <pubDate>Tue, 03 Nov 2026 02:55:45 +0000</pubDate>
      <link>https://dev.to/bowentse/i-built-a-client-side-webp-to-png-converter-5-lessons-27ma</link>
      <guid>https://dev.to/bowentse/i-built-a-client-side-webp-to-png-converter-5-lessons-27ma</guid>
      <description>&lt;p&gt;I downloaded a logo as a WebP file, tried to drop it into an older editor, and watched the editor refuse it. Renaming the file to .png did not help, because renaming is not converting. The file was still WebP data with a PNG name.&lt;/p&gt;

&lt;p&gt;That small annoyance turned into a weekend project. Disclosure first: I built this tool, so this is a build log, not a neutral review. The converter runs in the browser and never uploads your images: &lt;a href="https://toolkitloop.com/webp-to-png" rel="noopener noreferrer"&gt;https://toolkitloop.com/webp-to-png&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here are five lessons I took away from building it. None of them is about WebP alone. They are about what happens when a format conversion meets real files.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Conversion is decode, then encode — and the middle matters
&lt;/h2&gt;

&lt;p&gt;It is tempting to think of WebP to PNG as a file rename with extra steps. It is not. The browser decodes the WebP image into pixels, draws those pixels to a canvas, and then encodes the canvas as PNG or JPG. That middle step is where most surprises live.&lt;/p&gt;

&lt;p&gt;The decoded pixels are the truth. Everything after that point is a decision about how to store them. PNG stores them losslessly and keeps transparency. JPG stores them with lossy compression and no transparency. If you skip thinking about the middle step, you end up blaming the wrong end of the pipeline when an output looks wrong.&lt;/p&gt;

&lt;p&gt;A practical version of the core idea 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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;webpToPng&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="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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="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 snippet is simplified on purpose. A production converter needs error handling, a batch queue, a JPG path with a background fill, and a download step. But the shape is the lesson: decode to pixels, draw, then encode. If you keep that shape in mind, the rest of the decisions get easier.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Lossless output does not rescue a lossy source
&lt;/h2&gt;

&lt;p&gt;This is the misconception I see most often. People convert a lossy WebP to PNG and expect it to look better. It will not. PNG preserves the decoded image faithfully, including any compression artefacts that were already in the WebP.&lt;/p&gt;

&lt;p&gt;That is not a failure of PNG. It is the definition of lossless in this context: no additional loss during encoding. The source quality is the ceiling. Conversion changes the container. It does not invent detail.&lt;/p&gt;

&lt;p&gt;I think this distinction should be stated on every converter page, because users judge the tool by the wrong test otherwise. They compare the PNG to an imagined perfect original, instead of comparing it to the WebP they actually had. A good converter is a careful copy machine. It is not a restoration tool.&lt;/p&gt;

&lt;p&gt;If you need better quality, you need a better source file. If you only need compatibility, PNG is still the right choice for graphics and transparent images, even though the file will often be larger than the WebP.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Alpha is not a colour, and JPG proves it
&lt;/h2&gt;

&lt;p&gt;Transparency feels simple until you have to remove it. A transparent pixel is not white, black, or invisible ink. It is a pixel with an instruction: let the background show through. PNG can store that instruction. JPG cannot, so a JPG conversion must fill transparent areas with a background you select.&lt;/p&gt;

&lt;p&gt;That fill decision is a design decision, not a technical default. White is fine on a white page and wrong on a coloured banner. A dark fill is fine on a dark slide and wrong in a light document. Semi-transparent edges make it harder still, because a soft shadow or feathered edge blends with whatever background you choose.&lt;/p&gt;

&lt;p&gt;My rule after building this: keep a PNG master with transparency intact, and make JPG copies only for a specific use that needs a smaller photo file. The master protects you from redoing the conversion every time the background changes. Deleting the transparent original after one JPG export is the fastest way to create rework later.&lt;/p&gt;

&lt;p&gt;This is also where I would push back on converters that hide the background choice. A silent default feels convenient until the image lands somewhere unexpected. Making the choice visible is slightly more work up front and much less work later.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Batch conversion is a queue problem, not a speed problem
&lt;/h2&gt;

&lt;p&gt;Adding a small batch sounds like a loop: convert each file, download each result. In practice, the hard part is not speed. It is state. Which file failed? Which setting applied to which file? Did the user change the format halfway through the batch?&lt;/p&gt;

&lt;p&gt;A batch that mixes logos and photos in one run invites exactly that confusion. Logos need PNG and transparency. Photos usually need JPG and small size. One setting cannot serve both well. I now recommend keeping batches consistent: one goal, one format, one batch. Check the first and last outputs, not only the first one, because a problem that appears only on a detailed photo at the end of a batch is easy to miss.&lt;/p&gt;

&lt;p&gt;Memory matters too. Decoding several large images at once can strain a browser tab. Processing a small batch in order, releasing each bitmap after encoding, is less impressive in a demo and much more reliable in real use. Users remember a converter that finishes all ten files. They do not remember a converter that was fast for three files and then froze.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Privacy is architecture, not a promise
&lt;/h2&gt;

&lt;p&gt;Many converters say they respect privacy while still uploading files to a server for processing. A promise is easy to write. Architecture is harder to fake. If conversion runs in the browser, the file never needs to leave the device. That is a structural claim a user can reason about, not just a sentence to trust.&lt;/p&gt;

&lt;p&gt;That is why I kept this converter client-side with no signup. No account means no account database to protect, and no upload means no server copy to delete later. It does not make the tool perfect, but it removes a whole category of risk for screenshots, personal photos, and work documents.&lt;/p&gt;

&lt;p&gt;There is a limit to this argument, and it is worth stating. Browser-local processing still depends on the browser and the device. It does not make a file safe if the device itself is shared or compromised. Privacy claims should be specific: what leaves the device, what does not, and what the user still needs to check themselves. Vague claims help marketing and hurt trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I still argue about
&lt;/h2&gt;

&lt;p&gt;Here is the debatable point I promised. I default to PNG for anything with transparency or sharp graphics, and I accept JPG only for photos where size matters. Some developers argue the opposite workflow: default to JPG for everything except obvious logos, because smaller files load faster and most backgrounds are white anyway.&lt;/p&gt;

&lt;p&gt;I think that workflow optimises for the common case and pays for it in the edge case, which is exactly when a filled background becomes visible. But I can see the other side: if your images only ever live on white pages, the PNG master is extra weight you never use.&lt;/p&gt;

&lt;p&gt;So, where do you draw the line? Do you keep a transparent PNG master for every graphic, or do you accept a JPG background fill and move on? And when a client sends you a WebP file, what is the first format you reach for — and why?&lt;/p&gt;

&lt;p&gt;Try the converter I built here: &lt;a href="https://toolkitloop.com/webp-to-png" rel="noopener noreferrer"&gt;https://toolkitloop.com/webp-to-png&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you convert a tricky file, check the edges and the transparent areas at full size before you ship it. That one check catches more problems than any format debate.&lt;/p&gt;

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