<?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: Віталік Бакута</title>
    <description>The latest articles on DEV Community by Віталік Бакута (@__a275a1bef4).</description>
    <link>https://dev.to/__a275a1bef4</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%2F4241982%2F504dce90-a542-43a4-a01a-c515cf2c8987.jpg</url>
      <title>DEV Community: Віталік Бакута</title>
      <link>https://dev.to/__a275a1bef4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/__a275a1bef4"/>
    <language>en</language>
    <item>
      <title>The math behind Etsy fees: a tiny JavaScript model (and why cheap items hurt)</title>
      <dc:creator>Віталік Бакута</dc:creator>
      <pubDate>Fri, 13 Nov 2026 01:19:36 +0000</pubDate>
      <link>https://dev.to/__a275a1bef4/the-math-behind-etsy-fees-a-tiny-javascript-model-and-why-cheap-items-hurt-4h9c</link>
      <guid>https://dev.to/__a275a1bef4/the-math-behind-etsy-fees-a-tiny-javascript-model-and-why-cheap-items-hurt-4h9c</guid>
      <description>&lt;p&gt;Marketplace fees look simple until you try to put them in code. Some are percentages, some are fixed, they apply to different bases, and one of them depends on how the buyer found you.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://fee-math.com/" rel="noopener noreferrer"&gt;Fee Math&lt;/a&gt;, a set of free fee and profit calculators for Etsy, Amazon, eBay, TikTok Shop and payment processors. Etsy was the first one, and it's a nice small modelling exercise. Here is the whole thing in about 25 lines of JavaScript.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Rates below are Etsy's US defaults as last checked in October 2026. Fees change and differ by country, so always confirm on &lt;a href="https://www.etsy.com/legal/fees" rel="noopener noreferrer"&gt;Etsy's fee page&lt;/a&gt; before relying on them. This is not financial advice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fee lines
&lt;/h2&gt;

&lt;p&gt;For a typical US shop, one order can involve:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fee&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;Charged on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Listing fee&lt;/td&gt;
&lt;td&gt;$0.20&lt;/td&gt;
&lt;td&gt;per listing published or renewed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transaction fee&lt;/td&gt;
&lt;td&gt;6.5%&lt;/td&gt;
&lt;td&gt;item price &lt;strong&gt;+ shipping&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment processing&lt;/td&gt;
&lt;td&gt;3% + $0.25&lt;/td&gt;
&lt;td&gt;item price + shipping &lt;strong&gt;+ tax&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offsite Ads (only if the sale came from an ad)&lt;/td&gt;
&lt;td&gt;15% (12% for shops over $10k/year), capped at $100&lt;/td&gt;
&lt;td&gt;the order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Currency conversion (only if currencies differ)&lt;/td&gt;
&lt;td&gt;2.5%&lt;/td&gt;
&lt;td&gt;the order&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details people miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fees apply to &lt;strong&gt;shipping too&lt;/strong&gt;. Charging $5 for shipping costs you about $0.48 in fees.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;fixed&lt;/strong&gt; parts ($0.25 + $0.20) don't care about price, which matters a lot for cheap items (more on that below).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The model
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.065&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;processing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;processingFixed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;listing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;offsiteAds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;offsiteAdsCap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;conversion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.025&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;round&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;etsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shipping&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="n"&gt;tax&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="n"&gt;fromOffsiteAd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conversion&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shipping&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;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;processing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sale&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tax&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processing&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processingFixed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;listing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;listing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;offsiteAds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fromOffsiteAd&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="n"&gt;sale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;offsiteAds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;offsiteAdsCap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;conversion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;conversion&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;sale&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conversion&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="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="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&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;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&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="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lines&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;Each line is rounded to cents on its own, because that's how fees show up on a statement. If you build something bigger, store money as integer cents from the start and avoid float surprises altogether.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 1: a $25 item with $5 shipping
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;etsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shipping&lt;/span&gt;&lt;span class="p"&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="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// { transaction: 1.95, processing: 1.15, listing: 0.2,&lt;/span&gt;
&lt;span class="c1"&gt;//   offsiteAds: 0, conversion: 0, total: 3.3 }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revenue is $30, fees are $3.30 (11%). If the item costs $8 to make and $4.50 to ship, profit is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;30.00 - 3.30 - 8.00 - 4.50 = $14.20  (47% margin)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same order, but the buyer came through an Offsite Ad:&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="nf"&gt;etsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shipping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fromOffsiteAd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 7.8&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fees jump to $7.80 (26% of revenue) and profit drops to &lt;strong&gt;$9.70&lt;/strong&gt;. Same product, same buyer, one third less profit. That's why it's worth modelling Offsite Ads as a scenario, not ignoring it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 2: why cheap items hurt
&lt;/h2&gt;

&lt;p&gt;Look at fees as a share of the sale with free shipping:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item price&lt;/th&gt;
&lt;th&gt;Total fees&lt;/th&gt;
&lt;th&gt;Share of sale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;$10&lt;/td&gt;
&lt;td&gt;$1.40&lt;/td&gt;
&lt;td&gt;14.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$25&lt;/td&gt;
&lt;td&gt;$2.83&lt;/td&gt;
&lt;td&gt;11.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$100&lt;/td&gt;
&lt;td&gt;$9.95&lt;/td&gt;
&lt;td&gt;10.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The percentages are the same at every price; the $0.45 of fixed fees is what pushes the share up for low prices. Stickers, digital downloads and small accessories feel this the most. Bundling ("set of 3") is often the easiest fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Example 3: solving for the price you need
&lt;/h2&gt;

&lt;p&gt;The more useful question is usually the reverse: &lt;em&gt;what should I charge to make $X?&lt;/em&gt; With free shipping, no tax and no ads, fees are &lt;code&gt;0.095 × price + 0.45&lt;/code&gt;, so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;costs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;targetProfit&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.45&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="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.065&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;priceForProfit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;costs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adsRate&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;pct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processing&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;adsRate&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;fixed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processingFixed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="no"&gt;RATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;listing&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;ceil&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;costs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;fixed&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="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pct&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="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;priceForProfit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;12.5&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="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// 25.36&lt;/span&gt;
&lt;span class="nf"&gt;priceForProfit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;12.5&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="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 30.4  (if the sale comes from an Offsite Ad)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So to keep $10 on an item that costs you $12.50 including shipping, list it at about $25.36, or $30.40 if you want the same profit even on ad-driven sales. (The ad cap of $100 only kicks in for orders above roughly $667, so it's safe to ignore here.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What this model leaves out
&lt;/h2&gt;

&lt;p&gt;To keep it honest, it doesn't include income tax, VAT on Etsy's own fees, regulatory operating fees in some countries, Etsy Ads (on-site), subscriptions or shipping label costs. Payment processing rates also differ by country: for example 4% + £0.20 in the UK.&lt;/p&gt;

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

&lt;p&gt;If you sell on Etsy and don't want to maintain a script, the &lt;a href="https://fee-math.com/etsy/fee-calculator/" rel="noopener noreferrer"&gt;Etsy fee calculator&lt;/a&gt; on Fee Math does all of the above, shows every fee as a separate line, supports other countries and Offsite Ads, and gives you a shareable link or PDF. There are also calculators for &lt;a href="https://fee-math.com/amazon-fba/fba-profit-calculator/" rel="noopener noreferrer"&gt;Amazon FBA&lt;/a&gt;, &lt;a href="https://fee-math.com/ebay/fee-calculator/" rel="noopener noreferrer"&gt;eBay&lt;/a&gt;, &lt;a href="https://fee-math.com/tiktok-shop/fee-calculator/" rel="noopener noreferrer"&gt;TikTok Shop&lt;/a&gt; and &lt;a href="https://fee-math.com/payments/paypal-fee-calculator/" rel="noopener noreferrer"&gt;PayPal&lt;/a&gt;. The sources and "last checked" dates for every rate are on the &lt;a href="https://fee-math.com/methodology/" rel="noopener noreferrer"&gt;methodology page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which marketplace has the most confusing fee structure in your experience? I'm deciding what to model next.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>ecommerce</category>
      <category>beginners</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Convert images in the browser without uploading them (Canvas, createImageBitmap and WebAssembly)</title>
      <dc:creator>Віталік Бакута</dc:creator>
      <pubDate>Fri, 13 Nov 2026 01:13:05 +0000</pubDate>
      <link>https://dev.to/__a275a1bef4/convert-images-in-the-browser-without-uploading-them-canvas-createimagebitmap-and-webassembly-1pn5</link>
      <guid>https://dev.to/__a275a1bef4/convert-images-in-the-browser-without-uploading-them-canvas-createimagebitmap-and-webassembly-1pn5</guid>
      <description>&lt;p&gt;Most "free online converters" work the same way: you upload a file, a server converts it, you download the result. For a holiday photo that's fine. For a scanned passport, a contract or a client's design file, it's an unnecessary risk.&lt;/p&gt;

&lt;p&gt;While building &lt;a href="https://formatmath.com/" rel="noopener noreferrer"&gt;FormatMath&lt;/a&gt; I wanted the opposite: the file never leaves the device. Modern browsers can do most of this work on their own. Here is how, plus the gotchas I hit along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The basic pipeline: decode → draw → encode
&lt;/h2&gt;

&lt;p&gt;For formats the browser already understands (JPEG, PNG, WebP, GIF, BMP, often AVIF), conversion is three steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decode&lt;/strong&gt; the file into a bitmap with &lt;code&gt;createImageBitmap()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draw&lt;/strong&gt; it onto a canvas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encode&lt;/strong&gt; the canvas to the target format.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convertImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'image/jpeg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.9&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;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="n"&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="n"&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="n"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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="s1"&gt;'2d'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// JPEG has no transparency: paint a white background first,&lt;/span&gt;
  &lt;span class="c1"&gt;// otherwise transparent pixels turn black.&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'image/jpeg'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'#fff'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&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;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="n"&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="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&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="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// free memory early&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&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="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wiring it to a file input and a download link:&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="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'change'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;file&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="n"&gt;files&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="n"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;convertImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'image/webp'&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="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;a&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="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;\&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;^&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="o"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'.webp'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&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="nb"&gt;setTimeout&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="p"&gt;)&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;fetch&lt;/code&gt;, no &lt;code&gt;FormData&lt;/code&gt;, no server. Open DevTools → Network while converting and you'll see nothing go out. That's also the easiest way to verify any tool that claims "we don't upload your files".&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 1: the browser may silently ignore your format
&lt;/h2&gt;

&lt;p&gt;If the browser can't encode the type you asked for, &lt;code&gt;convertToBlob()&lt;/code&gt; / &lt;code&gt;toBlob()&lt;/code&gt; doesn't throw. It quietly returns a &lt;strong&gt;PNG&lt;/strong&gt;. Always check the result:&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`This browser can't encode ${type}, got ${blob.type}`&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;Encoder support varies more than decoder support, especially for WebP and AVIF output. When you need guaranteed output, ship an encoder compiled to WebAssembly (for example the codecs from the Squoosh project) instead of relying on the canvas.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: HEIC needs WebAssembly
&lt;/h2&gt;

&lt;p&gt;iPhone photos are HEIC by default. Safari can decode them natively, but Chrome and Firefox generally can't, so &lt;code&gt;createImageBitmap()&lt;/code&gt; will reject. The fix is a decoder compiled to WebAssembly, typically based on &lt;strong&gt;libheif&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the file as an &lt;code&gt;ArrayBuffer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Decode it in WASM to raw RGBA pixels.&lt;/li&gt;
&lt;li&gt;Put the pixels into &lt;code&gt;ImageData&lt;/code&gt; and continue with the canvas pipeline above.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same idea works for camera RAW, TIFF, PSD and other formats the browser doesn't know: compile a decoder, get pixels, reuse the encoder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: metadata disappears (usually a good thing)
&lt;/h2&gt;

&lt;p&gt;Drawing to a canvas keeps only pixels. EXIF data, including &lt;strong&gt;GPS location&lt;/strong&gt;, camera model and timestamps, is dropped. For privacy that's a feature: a photo re-encoded this way no longer leaks where it was taken.&lt;/p&gt;

&lt;p&gt;Two side effects to handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Orientation.&lt;/strong&gt; Phones often store a rotation flag instead of rotating pixels. Current browsers apply it in &lt;code&gt;createImageBitmap()&lt;/code&gt; by default (&lt;code&gt;imageOrientation: 'from-image'&lt;/code&gt;), but test with a portrait phone photo anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colour profiles.&lt;/strong&gt; Wide-gamut images (Display P3) can look slightly duller after conversion if you don't handle colour space. For most web use it's acceptable; for print work, it isn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to &lt;em&gt;keep&lt;/em&gt; metadata, you have to read it before decoding and write it back into the new file with a small EXIF library.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 4: big images and memory
&lt;/h2&gt;

&lt;p&gt;Canvas size is limited, and the limit differs by browser and device. Mobile Safari is the strictest. A 50-megapixel RAW file can fail where a 12 MP photo works fine. What helps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Do the work in a &lt;strong&gt;Web Worker&lt;/strong&gt; with &lt;code&gt;OffscreenCanvas&lt;/code&gt; so the UI doesn't freeze.&lt;/li&gt;
&lt;li&gt;Process files &lt;strong&gt;one at a time&lt;/strong&gt; in batch mode, and call &lt;code&gt;bitmap.close()&lt;/code&gt; / revoke object URLs as soon as you're done.&lt;/li&gt;
&lt;li&gt;Offer a downscale option for very large inputs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What about PDFs?
&lt;/h2&gt;

&lt;p&gt;Same philosophy, different libraries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PDF → image:&lt;/strong&gt; render each page with &lt;a href="https://mozilla.github.io/pdf.js/" rel="noopener noreferrer"&gt;pdf.js&lt;/a&gt; into a canvas, then encode as above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge, split, rotate:&lt;/strong&gt; &lt;a href="https://pdf-lib.js.org/" rel="noopener noreferrer"&gt;pdf-lib&lt;/a&gt; manipulates the document structure directly in JavaScript, without re-rendering pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compress:&lt;/strong&gt; usually means re-encoding embedded images at lower quality, which brings you back to the canvas or a WASM encoder.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is client-side always better?
&lt;/h2&gt;

&lt;p&gt;Not always. Server-side conversion still wins for very large batches, exotic formats with no WASM build, or when you need identical output on every device. But for everyday tasks like HEIC → JPG, PNG → WebP or merging two PDFs, the browser is more than capable, and the privacy story becomes "we never had your file" instead of "trust us, we delete it".&lt;/p&gt;

&lt;p&gt;If you just want the tools rather than the code, they're all on &lt;a href="https://formatmath.com/" rel="noopener noreferrer"&gt;FormatMath&lt;/a&gt;: &lt;a href="https://formatmath.com/heic-to-jpg/" rel="noopener noreferrer"&gt;HEIC to JPG&lt;/a&gt;, &lt;a href="https://formatmath.com/pdf-to-jpg/" rel="noopener noreferrer"&gt;PDF to JPG&lt;/a&gt;, &lt;a href="https://formatmath.com/merge-pdf/" rel="noopener noreferrer"&gt;Merge PDF&lt;/a&gt; and a set of &lt;a href="https://formatmath.com/developer-tools/" rel="noopener noreferrer"&gt;developer tools&lt;/a&gt;. Everything runs locally in your browser.&lt;/p&gt;

&lt;p&gt;Have you built something client-side that people assumed needed a server? I'd like to hear about it in the comments.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>privacy</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Can ChatGPT, Claude and Perplexity actually crawl your site? A 10-minute check</title>
      <dc:creator>Віталік Бакута</dc:creator>
      <pubDate>Fri, 13 Nov 2026 01:12:13 +0000</pubDate>
      <link>https://dev.to/__a275a1bef4/can-chatgpt-claude-and-perplexity-actually-crawl-your-site-a-10-minute-check-1geh</link>
      <guid>https://dev.to/__a275a1bef4/can-chatgpt-claude-and-perplexity-actually-crawl-your-site-a-10-minute-check-1geh</guid>
      <description>&lt;p&gt;More and more people find websites through AI assistants instead of a classic list of blue links. If an assistant can't fetch your pages, it can't quote them, summarize them or link to them. The annoying part: you can be blocked without ever deciding to block anyone.&lt;/p&gt;

&lt;p&gt;I ran into this while building &lt;a href="https://botready.app/" rel="noopener noreferrer"&gt;BotReady&lt;/a&gt;, a set of free checks for AI crawler access. Below is the manual version of the same checklist, so you can verify your own site with nothing more than a browser and a terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Know which bots you are dealing with
&lt;/h2&gt;

&lt;p&gt;AI companies usually run &lt;strong&gt;several&lt;/strong&gt; user agents, and they do different jobs. Blocking one does not block the others.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;th&gt;Training crawler&lt;/th&gt;
&lt;th&gt;Search / indexing&lt;/th&gt;
&lt;th&gt;User-triggered fetch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GPTBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;OAI-SearchBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ChatGPT-User&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ClaudeBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Claude-SearchBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Claude-User&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Perplexity&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PerplexityBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Perplexity-User&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Google-Extended&lt;/code&gt; (robots.txt token, not a separate bot)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Googlebot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apple&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Applebot-Extended&lt;/code&gt; (token)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Applebot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Common Crawl&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CCBot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical takeaway: "I don't want my content used for training" and "I want to show up when someone asks ChatGPT about my product" are &lt;strong&gt;two different robots.txt decisions&lt;/strong&gt;. Many sites block everything with one rule and lose visibility they actually wanted.&lt;/p&gt;

&lt;p&gt;Also note that user-triggered fetchers (a person pasting your URL into a chat) are treated differently by some vendors and may not follow robots.txt the same way a crawler does. Check each vendor's docs for the current behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Read your robots.txt like a bot would
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;https://yourdomain.com/robots.txt&lt;/code&gt; and look for three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A blanket &lt;code&gt;User-agent: *&lt;/code&gt; + &lt;code&gt;Disallow: /&lt;/code&gt; (blocks everyone, including AI bots).&lt;/li&gt;
&lt;li&gt;Explicit &lt;code&gt;Disallow&lt;/code&gt; rules for the bots above, often added by a CMS plugin or a security preset you forgot about.&lt;/li&gt;
&lt;li&gt;Groups that accidentally match. A bot follows the &lt;strong&gt;most specific&lt;/strong&gt; &lt;code&gt;User-agent&lt;/code&gt; group that matches it and ignores the rest, so a &lt;code&gt;GPTBot&lt;/code&gt; group without an &lt;code&gt;Allow&lt;/code&gt; can override your generous &lt;code&gt;*&lt;/code&gt; group.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A common "search yes, training no" setup looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Allow AI search / answer engines
User-agent: OAI-SearchBot
User-agent: Claude-SearchBot
User-agent: PerplexityBot
Allow: /

# Opt out of model training
User-agent: GPTBot
User-agent: ClaudeBot
User-agent: Google-Extended
User-agent: Applebot-Extended
User-agent: CCBot
Disallow: /

User-agent: *
Allow: /

Sitemap: https://yourdomain.com/sitemap.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a policy choice, not a recommendation: decide what you want, then make the file say exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Check the layer robots.txt can't see: your CDN or firewall
&lt;/h2&gt;

&lt;p&gt;This is the one that surprises people most. robots.txt is a polite request. A CDN or WAF rule is a hard block that returns &lt;code&gt;403&lt;/code&gt; before your server is even involved.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; has a one-click setting to block AI bots, and newer zones can have AI crawler blocking turned on by default. Look in the dashboard under the bots / AI crawl control section.&lt;/li&gt;
&lt;li&gt;Hosting "bot protection" and security plugins sometimes challenge any unknown user agent with a JavaScript or CAPTCHA page. A crawler can't solve that, so it sees an empty page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick first test from your terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -o /dev/null -w &lt;span class="s2"&gt;"%{http_code}\n"&lt;/span&gt; \
  -A &lt;span class="s2"&gt;"Mozilla/5.0 (compatible; GPTBot/1.2; +https://openai.com/gptbot)"&lt;/span&gt; \
  https://yourdomain.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeat with &lt;code&gt;ClaudeBot&lt;/code&gt;, &lt;code&gt;PerplexityBot&lt;/code&gt;, etc. &lt;code&gt;200&lt;/code&gt; is good; &lt;code&gt;403&lt;/code&gt;, &lt;code&gt;429&lt;/code&gt; or &lt;code&gt;503&lt;/code&gt; deserves a look.&lt;/p&gt;

&lt;p&gt;One caveat: spoofing the user agent from your laptop is &lt;strong&gt;not&lt;/strong&gt; the same as a real crawler request. Many firewalls verify bots by IP range, so a fake GPTBot may be blocked while the real one is allowed, or the other way around. The test tells you whether UA-based rules exist; your logs tell you the truth (step 5).&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Make sure there is something to read without JavaScript
&lt;/h2&gt;

&lt;p&gt;Most AI crawlers fetch raw HTML and do not run your JavaScript. If your page is a client-side SPA, they may get an empty &lt;code&gt;&amp;lt;div id="root"&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s https://yourdomain.com/your-page &lt;span class="o"&gt;|&lt;/span&gt; grep -i &lt;span class="s2"&gt;"a sentence from your page"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the text you care about isn't in the raw HTML, consider SSR, static generation or prerendering for content pages. While you're there, check that the page has a clear &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, one &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;, a meta description and valid structured data. These help any machine reader, not only AI ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Look at your server logs
&lt;/h2&gt;

&lt;p&gt;This is the only place you see what really happened. Search access logs for the bot names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;grep -Ei &lt;span class="s2"&gt;"gptbot|oai-searchbot|chatgpt-user|claudebot|claude-searchbot|claude-user|perplexitybot|perplexity-user"&lt;/span&gt; access.log \
  &lt;span class="o"&gt;|&lt;/span&gt; awk &lt;span class="s1"&gt;'{print $9}'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; sort &lt;span class="o"&gt;|&lt;/span&gt; uniq -c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints the status codes each bot received. Lots of &lt;code&gt;403&lt;/code&gt;s mean something is blocking them. No hits at all may mean they never got past the CDN (check CDN logs) or simply haven't discovered the site yet. Anyone can fake a user agent, so verify important hits against the vendor's published IP ranges.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Optional: llms.txt
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/llms.txt&lt;/code&gt; is a proposed Markdown file that gives AI tools a short map of your most important pages. It's not a standard that crawlers are required to use, and it doesn't replace robots.txt or a sitemap. It's cheap to add, though, and handy for documentation sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Decide separately on &lt;strong&gt;training&lt;/strong&gt; and &lt;strong&gt;AI search&lt;/strong&gt; access.&lt;/li&gt;
&lt;li&gt;Make robots.txt say exactly that, per user agent.&lt;/li&gt;
&lt;li&gt;Check the CDN/WAF, because it overrides everything else.&lt;/li&gt;
&lt;li&gt;Serve real content in the HTML.&lt;/li&gt;
&lt;li&gt;Confirm in your logs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you'd rather not do it by hand, the &lt;a href="https://botready.app/ai-crawler-checker/" rel="noopener noreferrer"&gt;AI Crawler Checker&lt;/a&gt; and &lt;a href="https://botready.app/ai-visibility-checker/" rel="noopener noreferrer"&gt;AI Visibility Checker&lt;/a&gt; on BotReady run these checks for a URL in one go, and the &lt;a href="https://botready.app/ai-robots-txt-generator/" rel="noopener noreferrer"&gt;robots.txt generator&lt;/a&gt; writes the per-bot rules for you. Free, no sign-up for the first checks.&lt;/p&gt;

&lt;p&gt;What did you find on your own site? I'm curious how many people discover a forgotten firewall rule. 👀&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
