<?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: sharefun2023</title>
    <description>The latest articles on DEV Community by sharefun2023 (@sharefun2023).</description>
    <link>https://dev.to/sharefun2023</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%2F4038465%2F0efe338c-5e60-4e6d-ae98-f674cfd4a893.png</url>
      <title>DEV Community: sharefun2023</title>
      <link>https://dev.to/sharefun2023</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sharefun2023"/>
    <language>en</language>
    <item>
      <title>PostgreSQL vs MySQL: 7 syntax differences that break migrations</title>
      <dc:creator>sharefun2023</dc:creator>
      <pubDate>Tue, 29 Sep 2026 07:49:53 +0000</pubDate>
      <link>https://dev.to/sharefun2023/postgresql-vs-mysql-7-syntax-differences-that-break-migrations-4d52</link>
      <guid>https://dev.to/sharefun2023/postgresql-vs-mysql-7-syntax-differences-that-break-migrations-4d52</guid>
      <description>&lt;p&gt;Porting a working MySQL schema to PostgreSQL is rarely a find-and-replace. The SQL standard leaves enough room that the two engines disagree on quoting, types, upserts, and pagination — and most of the disagreements fail at runtime, not at parse time.&lt;/p&gt;

&lt;p&gt;Here are the seven that cost me the most time.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Identifier quoting is not the same character
&lt;/h2&gt;

&lt;p&gt;MySQL uses backticks, PostgreSQL uses double quotes (which is the ANSI standard):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;`order`&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;-- MySQL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"order"&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;-- PostgreSQL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two consequences people miss. First, backticks in PostgreSQL are a syntax error, so any generated query with backtick quoting must be rewritten. Second, &lt;strong&gt;double quotes in MySQL are legal but dangerous&lt;/strong&gt;: with the default &lt;code&gt;ANSI_QUOTES&lt;/code&gt; off, MySQL treats &lt;code&gt;"order"&lt;/code&gt; as a &lt;em&gt;string literal&lt;/em&gt;, not an identifier. So a query that reads fine in PostgreSQL can silently evaluate to a constant in MySQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. String concatenation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;first_name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;last_name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- SQL Server style, breaks in both&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first_name&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="n"&gt;last_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- works in both&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL also supports the standard &lt;code&gt;||&lt;/code&gt; operator. MySQL has &lt;code&gt;||&lt;/code&gt; too, but by default it means logical &lt;code&gt;OR&lt;/code&gt; — so this query is not an error in MySQL, it is a silent logic change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;first_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;last_name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- MySQL: 0 or 1, not a string&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CONCAT()&lt;/code&gt; is the portable choice. One more difference: PostgreSQL's &lt;code&gt;||&lt;/code&gt; propagates &lt;code&gt;NULL&lt;/code&gt; (the whole expression becomes &lt;code&gt;NULL&lt;/code&gt;), while MySQL's &lt;code&gt;CONCAT()&lt;/code&gt; also returns &lt;code&gt;NULL&lt;/code&gt; if any argument is &lt;code&gt;NULL&lt;/code&gt; — but &lt;code&gt;CONCAT_WS()&lt;/code&gt; skips &lt;code&gt;NULL&lt;/code&gt; arguments instead, which is usually what you want for address lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Auto-increment
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- MySQL&lt;/span&gt;
&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;

&lt;span class="c1"&gt;-- PostgreSQL, modern&lt;/span&gt;
&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;

&lt;span class="c1"&gt;-- PostgreSQL, legacy but still everywhere&lt;/span&gt;
&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SERIAL&lt;/code&gt; is not a real type — it is shorthand that PostgreSQL expands into an integer column plus a sequence and a default. The practical trap: &lt;code&gt;SERIAL&lt;/code&gt; creates an implicit sequence with its own name, and &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt; plus manual ID inserts can leave that sequence behind the actual max ID. Then the next insert fails with a duplicate key. &lt;code&gt;GENERATED ... AS IDENTITY&lt;/code&gt; ties the sequence to the column properly and is what you should write in new schemas.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Upsert syntax is completely different
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- MySQL&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;counters&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;DUPLICATE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- PostgreSQL&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;counters&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;counters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to watch. &lt;code&gt;ON CONFLICT&lt;/code&gt; requires you to name the conflict target — without &lt;code&gt;(id)&lt;/code&gt;, PostgreSQL cannot tell which unique constraint you mean. The existing row is referenced through the table name, not &lt;code&gt;VALUES()&lt;/code&gt;. And MySQL's &lt;code&gt;INSERT IGNORE&lt;/code&gt; has no direct PostgreSQL equivalent; it silently becomes &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt;, which is &lt;em&gt;not&lt;/em&gt; the same statement — &lt;code&gt;INSERT IGNORE&lt;/code&gt; also swallows type conversion and truncation problems, which &lt;code&gt;DO NOTHING&lt;/code&gt; does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Pagination
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;OFFSET&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- both engines&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&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="c1"&gt;-- MySQL only&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comma form is a MySQL extension. In PostgreSQL &lt;code&gt;LIMIT 20, 10&lt;/code&gt; is a syntax error. PostgreSQL also accepts the standard &lt;code&gt;OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY&lt;/code&gt;, which MySQL does not.&lt;/p&gt;

&lt;p&gt;The deeper point applies to both engines: large &lt;code&gt;OFFSET&lt;/code&gt; values force the database to generate and discard every skipped row. Past a few thousand, switch to keyset pagination — &lt;code&gt;WHERE id &amp;gt; :last_seen_id ORDER BY id LIMIT 10&lt;/code&gt; — which stays fast regardless of depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Booleans are not a type in MySQL
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL&lt;/span&gt;
&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;

&lt;span class="c1"&gt;-- MySQL: BOOLEAN is an alias for TINYINT(1)&lt;/span&gt;
&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="nb"&gt;BOOLEAN&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;TRUE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MySQL stores it as a number, which is why &lt;code&gt;WHERE active = 1&lt;/code&gt; works there and &lt;code&gt;WHERE active = true&lt;/code&gt; in PostgreSQL is correct while &lt;code&gt;= 1&lt;/code&gt; fails with an operator does not exist error. Porting conditionals between the two means touching every boolean comparison, not just the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Case sensitivity of identifiers
&lt;/h2&gt;

&lt;p&gt;PostgreSQL folds unquoted identifiers to lowercase, so &lt;code&gt;SELECT * FROM Orders&lt;/code&gt; and &lt;code&gt;FROM orders&lt;/code&gt; are the same table. MySQL's behaviour depends on the filesystem and &lt;code&gt;lower_case_table_names&lt;/code&gt;: case-insensitive on Windows and macOS by default, case-sensitive on Linux. A migration where both engines run on Linux is fine, and then a teammate runs the MySQL copy on a Mac and half the queries break.&lt;/p&gt;

&lt;p&gt;Practical rule: pick lowercase snake_case for every identifier and never rely on case-insensitive matching.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on string comparison
&lt;/h2&gt;

&lt;p&gt;PostgreSQL's &lt;code&gt;LIKE&lt;/code&gt; is case-sensitive; MySQL's &lt;code&gt;LIKE&lt;/code&gt; follows the column collation, which is case-insensitive by default for &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;. The same filter returns different row counts. PostgreSQL's &lt;code&gt;ILIKE&lt;/code&gt; gives you the case-insensitive behaviour explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you migrate
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Replace backticks with double quotes (and audit every existing double-quoted string in MySQL).&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;||&lt;/code&gt; and &lt;code&gt;+&lt;/code&gt; with &lt;code&gt;CONCAT()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Rewrite &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; to &lt;code&gt;GENERATED AS IDENTITY&lt;/code&gt;, then resync sequences on any table that received explicit IDs.&lt;/li&gt;
&lt;li&gt;Convert &lt;code&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt; to &lt;code&gt;ON CONFLICT (target) DO UPDATE&lt;/code&gt; and re-check anything using &lt;code&gt;INSERT IGNORE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fix every &lt;code&gt;LIMIT offset, count&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Type-check every boolean column and every boolean predicate.&lt;/li&gt;
&lt;li&gt;Normalise identifier case before touching anything else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to see the two flavours side by side while rewriting, the &lt;a href="https://sqlformat.io/dialect-converter" rel="noopener noreferrer"&gt;SQL dialect converter&lt;/a&gt; shows the differences for a pasted query, and it runs entirely in the browser. The full comparison table, including date functions and interval arithmetic, is in &lt;a href="https://sqlformat.io/blog/postgresql-vs-mysql-syntax-differences" rel="noopener noreferrer"&gt;PostgreSQL vs MySQL syntax differences&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>mysql</category>
      <category>database</category>
      <category>sql</category>
    </item>
    <item>
      <title>The NOT IN trap: why your SQL query returns zero rows</title>
      <dc:creator>sharefun2023</dc:creator>
      <pubDate>Tue, 29 Sep 2026 07:49:26 +0000</pubDate>
      <link>https://dev.to/sharefun2023/the-not-in-trap-why-your-sql-query-returns-zero-rows-2b56</link>
      <guid>https://dev.to/sharefun2023/the-not-in-trap-why-your-sql-query-returns-zero-rows-2b56</guid>
      <description>&lt;p&gt;A query that returns an empty result is usually a wrong &lt;code&gt;WHERE&lt;/code&gt; clause. But there is one class of empty result that confuses even experienced people, because every row it filters on looks correct: &lt;code&gt;NOT IN&lt;/code&gt; with a &lt;code&gt;NULL&lt;/code&gt; in the subquery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure
&lt;/h2&gt;

&lt;p&gt;Two tables. Customers, and the orders they placed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;VALUES&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="s1"&gt;'Ada'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Grace'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'Linus'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Customer 3 has never ordered. So this should return Linus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns &lt;strong&gt;nothing&lt;/strong&gt;. Not Linus, not anybody. Zero rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;NOT IN&lt;/code&gt; is shorthand for a chain of &lt;code&gt;AND&lt;/code&gt;-ed comparisons:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;      &lt;span class="c1"&gt;-- for Linus: 3 &amp;lt;&amp;gt; 1 AND 3 &amp;lt;&amp;gt; NULL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;3 &amp;lt;&amp;gt; NULL&lt;/code&gt; does not evaluate to true. It evaluates to &lt;code&gt;UNKNOWN&lt;/code&gt;, because &lt;code&gt;NULL&lt;/code&gt; means "unknown value" — and you cannot assert that 3 is different from a value you do not know.&lt;/p&gt;

&lt;p&gt;SQL uses three-valued logic: every comparison is &lt;code&gt;TRUE&lt;/code&gt;, &lt;code&gt;FALSE&lt;/code&gt;, or &lt;code&gt;UNKNOWN&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Expression&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3 = 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FALSE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3 &amp;lt;&amp;gt; 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TRUE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3 = NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UNKNOWN&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3 &amp;lt;&amp;gt; NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UNKNOWN&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NULL = NULL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UNKNOWN&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;WHERE&lt;/code&gt; keeps a row only when the condition is &lt;code&gt;TRUE&lt;/code&gt;. &lt;code&gt;TRUE AND UNKNOWN&lt;/code&gt; is &lt;code&gt;UNKNOWN&lt;/code&gt;. So the single &lt;code&gt;NULL&lt;/code&gt; in the orders table poisons the comparison for every customer, and the whole result collapses to empty.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;NULL = NULL&lt;/code&gt; is &lt;code&gt;UNKNOWN&lt;/code&gt; too. &lt;code&gt;NULL&lt;/code&gt; is not equal to itself — it is not a value, it is the absence of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to fix it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fix 1 — filter the NULLs out of the subquery.&lt;/strong&gt; Cheapest, and usually the right answer when the NULL is meaningless data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix 2 — use NOT EXISTS.&lt;/strong&gt; This is the version I reach for by default, because it is null-safe by construction: &lt;code&gt;EXISTS&lt;/code&gt; returns a plain boolean about whether rows matched, and the comparison inside is evaluated per row rather than collapsed into a set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix 3 — LEFT JOIN with an IS NULL check.&lt;/strong&gt; The classic anti-join:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three return Linus. Pick by readability and by what your optimizer likes — modern planners usually turn all three into the same anti-join.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same trap in plain comparisons
&lt;/h2&gt;

&lt;p&gt;Once you internalise that &lt;code&gt;NULL&lt;/code&gt; is &lt;code&gt;UNKNOWN&lt;/code&gt;, other surprises stop being surprising:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- rows with NULL are excluded&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- always empty&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- this is the one that works&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;COUNT(*)&lt;/code&gt; counts rows; &lt;code&gt;COUNT(column)&lt;/code&gt; counts non-NULL values. For the orders above, &lt;code&gt;COUNT(*)&lt;/code&gt; is 2 and &lt;code&gt;COUNT(customer_id)&lt;/code&gt; is 1. That difference has broken more dashboards than any other SQL subtlety — an average computed as &lt;code&gt;SUM(x)/COUNT(*)&lt;/code&gt; silently treats missing values as zeros.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NOT IN&lt;/code&gt; against a subquery that can produce &lt;code&gt;NULL&lt;/code&gt; returns zero rows. Always.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IS NULL&lt;/code&gt; / &lt;code&gt;IS NOT NULL&lt;/code&gt; are the only operators that test for &lt;code&gt;NULL&lt;/code&gt; directly.&lt;/li&gt;
&lt;li&gt;Prefer &lt;code&gt;NOT EXISTS&lt;/code&gt; for anti-joins; it removes an entire category of bug.&lt;/li&gt;
&lt;li&gt;Aggregates ignore &lt;code&gt;NULL&lt;/code&gt;, which changes denominators.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are porting queries between engines, note that &lt;code&gt;COALESCE&lt;/code&gt; is standard while &lt;code&gt;IFNULL&lt;/code&gt; (MySQL/SQLite), &lt;code&gt;NVL&lt;/code&gt; (Oracle), and &lt;code&gt;ISNULL&lt;/code&gt; (SQL Server) are dialect-specific — &lt;code&gt;COALESCE&lt;/code&gt; works everywhere.&lt;/p&gt;

&lt;p&gt;I wrote up the full set of NULL behaviours, including truth tables, &lt;code&gt;NULLIF&lt;/code&gt;, and how each engine sorts NULLs, at &lt;a href="https://sqlformat.io/blog/sql-null-handling-guide" rel="noopener noreferrer"&gt;SQL NULL handling: common pitfalls and fixes&lt;/a&gt;. And if you want to eyeball a query's structure before running it, the &lt;a href="https://sqlformat.io/" rel="noopener noreferrer"&gt;online SQL formatter&lt;/a&gt; is client-side — the text never leaves the browser.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>postgres</category>
      <category>mysql</category>
    </item>
    <item>
      <title>ROW_NUMBER vs RANK vs DENSE_RANK: one of them silently duplicates your Top-N</title>
      <dc:creator>sharefun2023</dc:creator>
      <pubDate>Tue, 29 Sep 2026 07:49:19 +0000</pubDate>
      <link>https://dev.to/sharefun2023/rownumber-vs-rank-vs-denserank-one-of-them-silently-duplicates-your-top-n-i6a</link>
      <guid>https://dev.to/sharefun2023/rownumber-vs-rank-vs-denserank-one-of-them-silently-duplicates-your-top-n-i6a</guid>
      <description>&lt;p&gt;I was reviewing a "top 3 products per category" report last week and found a bug that had been live for months. One category showed four products. Another showed the same product twice.&lt;/p&gt;

&lt;p&gt;The query used &lt;code&gt;RANK()&lt;/code&gt;. It should have used &lt;code&gt;ROW_NUMBER()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;They look interchangeable in a tutorial and behave completely differently the moment your data has ties.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Here is a small sales table with an obvious tie — two products in the same category sold 120 units:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;product_sales&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;category&lt;/span&gt;   &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;product&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;units_sold&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;product_sales&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tools'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Hammer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tools'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Wrench'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tools'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Saw'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tools'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Drill'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two products are tied at 120, one at 95, one at 80. What does "top 3" mean here? That depends entirely on which ranking function you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each function actually returns
&lt;/h2&gt;

&lt;p&gt;All three are window functions, so they need an &lt;code&gt;OVER (PARTITION BY ... ORDER BY ...)&lt;/code&gt; clause. The difference is what they do with equal values:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;Ties get&lt;/th&gt;
&lt;th&gt;Result on the data above&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ROW_NUMBER()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;arbitrary distinct numbers&lt;/td&gt;
&lt;td&gt;1, 2, 3, 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RANK()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same rank, then it &lt;strong&gt;skips&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;1, 1, 3, 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DENSE_RANK()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same rank, then it &lt;strong&gt;does not skip&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;1, 1, 2, 3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;units_sold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;       &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rnk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;DENSE_RANK&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;drnk&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;product_sales&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 plaintext"&gt;&lt;code&gt;product | units_sold | rn | rnk | drnk
--------+------------+----+-----+-----
Hammer  |        120 |  1 |   1 |    1
Wrench  |        120 |  2 |   1 |    1
Saw     |         95 |  3 |   3 |    2
Drill   |         80 |  4 |   4 |    3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;RANK()&lt;/code&gt; jumps from 1 to 3. That gap is by design — it answers "how many rows are strictly better than this one, plus one". &lt;code&gt;ROW_NUMBER()&lt;/code&gt; answers "give me a stable row index". Those are different questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How RANK() silently breaks "top N per group"
&lt;/h2&gt;

&lt;p&gt;Now add the category column back and filter to the top 3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rnk&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;product_sales&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rnk&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get &lt;strong&gt;four rows&lt;/strong&gt; for &lt;code&gt;tools&lt;/code&gt;: Hammer, Wrench, Saw, Drill. Both tied products share rank 1, then Saw gets rank 3 and Drill also gets rank 3 — so &lt;code&gt;rnk &amp;lt;= 3&lt;/code&gt; keeps all four.&lt;/p&gt;

&lt;p&gt;That is the bug. The report says "top 3" and emits 4 rows. If a downstream system paginates on the count, the totals are wrong. If a human reads it, they wonder why the list is one item longer.&lt;/p&gt;

&lt;p&gt;Swap in &lt;code&gt;ROW_NUMBER()&lt;/code&gt; and you get exactly 3 rows — but now which of Hammer/Wrench wins is undefined, because the &lt;code&gt;ORDER BY&lt;/code&gt; has no tiebreaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: ROW_NUMBER with an explicit tiebreaker
&lt;/h2&gt;

&lt;p&gt;Never let the database decide ties for you. Add a deterministic second sort key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;
      &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;product&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;   &lt;span class="c1"&gt;-- tiebreaker&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rn&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;product_sales&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;rn&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the result is stable and reproducible: the same query returns the same three rows every time, in every environment. When someone asks "why is Wrench above Hammer?", the answer is a rule you wrote down, not whatever order the storage engine happened to return.&lt;/p&gt;

&lt;p&gt;If ties genuinely deserve equal standing and you want &lt;em&gt;all&lt;/em&gt; of them, then &lt;code&gt;RANK()&lt;/code&gt; is correct — just be aware you are asking for "at least N".&lt;/p&gt;

&lt;h2&gt;
  
  
  When DENSE_RANK is what you actually want
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;DENSE_RANK()&lt;/code&gt; is for "which distinct tier is this value in", where you do not want gaps. Ranking price bands, severity levels, or score buckets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;units_sold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;DENSE_RANK&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;units_sold&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;product_sales&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get tiers 1, 1, 2, 3 — consecutive, no holes. That reads correctly as "there are three distinct performance levels here". If you used &lt;code&gt;RANK()&lt;/code&gt;, the output would say tier 3 for a value that is only the second-best level, which is misleading in a label.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that bite people
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. ROW_NUMBER without a tiebreaker is non-deterministic.&lt;/strong&gt; The database is free to return either tied row first, and it can change between runs, after an &lt;code&gt;ANALYZE&lt;/code&gt;, or after a version upgrade. If the result feeds anything with a count or a diff, you will eventually see a phantom change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Window functions need the filter outside the query.&lt;/strong&gt; You cannot write &lt;code&gt;WHERE ROW_NUMBER() OVER (...) &amp;lt;= 3&lt;/code&gt; — window functions are evaluated after &lt;code&gt;WHERE&lt;/code&gt;. The subquery (or a CTE) is required, not stylistic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Version support.&lt;/strong&gt; Window functions landed in MySQL 8.0, PostgreSQL 8.4, SQL Server 2005, and SQLite 3.25. On MySQL 5.7 there is no &lt;code&gt;ROW_NUMBER()&lt;/code&gt; at all, and the usual workaround is a correlated subquery counting better rows — which is also the clearest way to understand what the rank means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;units_sold&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;product_sales&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;product_sales&lt;/span&gt; &lt;span class="n"&gt;p2&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;units_sold&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;units_sold&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That counts how many rows beat this one, which is exactly what &lt;code&gt;RANK()&lt;/code&gt; computes — and it has the same tie behaviour, including the extra rows.&lt;/p&gt;

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

&lt;p&gt;Reach for &lt;code&gt;ROW_NUMBER()&lt;/code&gt; when you mean "give me N rows". Reach for &lt;code&gt;RANK()&lt;/code&gt; when you mean "give me everything at least as good as the Nth". Reach for &lt;code&gt;DENSE_RANK()&lt;/code&gt; when the number is a tier label rather than a position.&lt;/p&gt;

&lt;p&gt;And whenever you use &lt;code&gt;ROW_NUMBER()&lt;/code&gt; for a Top-N, put a tiebreaker in the &lt;code&gt;ORDER BY&lt;/code&gt;. It costs nothing and it makes the query reproducible.&lt;/p&gt;

&lt;p&gt;If you want to check how a slow or deeply nested query looks after cleanup, the &lt;a href="https://sqlformat.io/" rel="noopener noreferrer"&gt;free online SQL formatter&lt;/a&gt; runs entirely in your browser — no query text leaves your machine. I wrote up the full window function reference, including running totals and &lt;code&gt;LAG&lt;/code&gt;/&lt;code&gt;LEAD&lt;/code&gt; comparisons, at &lt;a href="https://sqlformat.io/blog/sql-window-functions-guide" rel="noopener noreferrer"&gt;SQL window functions explained&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>postgres</category>
      <category>mysql</category>
    </item>
    <item>
      <title>Your PNG quality slider does nothing — I measured canvas.toBlob() for PNG, JPEG and WebP</title>
      <dc:creator>sharefun2023</dc:creator>
      <pubDate>Sat, 26 Sep 2026 12:38:20 +0000</pubDate>
      <link>https://dev.to/sharefun2023/your-png-quality-slider-does-nothing-i-measured-canvastoblob-for-png-jpeg-and-webp-7l2</link>
      <guid>https://dev.to/sharefun2023/your-png-quality-slider-does-nothing-i-measured-canvastoblob-for-png-jpeg-and-webp-7l2</guid>
      <description>&lt;p&gt;Every image tool on the web has a "quality" slider. Almost nobody tells you which formats actually obey it.&lt;/p&gt;

&lt;p&gt;I build client-side image tools, so the slider isn't cosmetic for me — it's the whole feature. I sat down with a headless Chrome and measured what &lt;code&gt;canvas.toBlob()&lt;/code&gt; really does with that argument, format by format. Here are the raw numbers, and the two places where the mental model everyone has is simply wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;One 1200×800 canvas, deliberately "photo-like" so a lossy encoder has something to chew on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a three-stop linear gradient across the whole frame,&lt;/li&gt;
&lt;li&gt;6,000 semi-transparent noise rectangles (4×4 px),&lt;/li&gt;
&lt;li&gt;72 px monospace text on top.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I encoded the same pixels over and over with different quality values. Chrome 149 headless, Linux, nothing exotic. Everything below is &lt;code&gt;canvas.toBlob(callback, type, quality)&lt;/code&gt; on identical source pixels — no file I/O, no libraries.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format / quality&lt;/th&gt;
&lt;th&gt;Output bytes&lt;/th&gt;
&lt;th&gt;vs. the best case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PNG, q = 0.1&lt;/td&gt;
&lt;td&gt;1,345,909&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG, q = 1.0&lt;/td&gt;
&lt;td&gt;1,345,909&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0% change&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPEG, q = 1.0&lt;/td&gt;
&lt;td&gt;962,887&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPEG, q = 0.8&lt;/td&gt;
&lt;td&gt;96,345&lt;/td&gt;
&lt;td&gt;−90.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPEG, q = 0.6&lt;/td&gt;
&lt;td&gt;57,971&lt;/td&gt;
&lt;td&gt;−94.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPEG, q = 0.1&lt;/td&gt;
&lt;td&gt;12,124&lt;/td&gt;
&lt;td&gt;−98.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP, q = 1.0&lt;/td&gt;
&lt;td&gt;477,822&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP, q = 0.8&lt;/td&gt;
&lt;td&gt;68,194&lt;/td&gt;
&lt;td&gt;−85.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP, q = 0.3&lt;/td&gt;
&lt;td&gt;26,504&lt;/td&gt;
&lt;td&gt;−94.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Finding 1: PNG ignores the quality argument completely
&lt;/h2&gt;

&lt;p&gt;Not "a little", not "with a small effect". Byte-identical. 1,345,909 bytes at q = 0.1 and at q = 1.0, and a second 200×200 canvas gave 17,279 bytes at both settings too.&lt;/p&gt;

&lt;p&gt;The reason is boring once you see it: PNG is a lossless format. The ninth argument of the WebP/JPEG encoder is a quantisation target; PNG has no such concept. Chrome's PNG encoder takes the quality value and throws it away.&lt;/p&gt;

&lt;p&gt;Which means every product page claiming &lt;em&gt;"PNG compression: set quality to 80% and save 50%"&lt;/em&gt; is describing something no browser can do. If you see a PNG quality slider, it is decoration.&lt;/p&gt;

&lt;p&gt;What actually makes a PNG smaller:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;fewer pixels&lt;/strong&gt; — downscaling is the only lever that always works,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fewer colours&lt;/strong&gt; — converting to a palette and quantising (that needs a real PNG encoder such as UPNG/ImageQuant, not &lt;code&gt;toBlob&lt;/code&gt;),&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;a different format&lt;/strong&gt; — for photographic content WebP or AVIF wins outright, and both keep transparency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Finding 2: WebP beat JPEG at &lt;em&gt;every&lt;/em&gt; quality setting I tried
&lt;/h2&gt;

&lt;p&gt;Look at the table again. Same pixels, same machine, same call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;q = 1.0 → JPEG 962,887 vs WebP &lt;strong&gt;477,822&lt;/strong&gt; (WebP is half the size),&lt;/li&gt;
&lt;li&gt;q = 0.8 → JPEG 96,345 vs WebP &lt;strong&gt;68,194&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Chrome's &lt;code&gt;image/jpeg&lt;/code&gt; encoder at quality 1.0 is still a lossy encode — it just targets a very high fidelity and stops caring about size. WebP's q = 1.0 is also lossy, but its ceiling is far cheaper.&lt;/p&gt;

&lt;p&gt;The practical takeaway: if the browser can encode WebP, "I'll use JPEG because WebP might be bigger" is not a good default any more. Check it per image if you care, but on photographic pixels the ordering held for me across the whole range.&lt;/p&gt;

&lt;p&gt;Two caveats worth writing down, because someone will hit them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The curve is source-dependent.&lt;/strong&gt; My synthetic gradient plus noise is a compression-friendly worst case for JPEG (smooth areas plus fine detail). Flat vector-ish artwork compresses very differently and the percentages shrink a lot. Never quote a compression percentage without saying what image it came from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;q = 1.0 JPEG is still lossy.&lt;/strong&gt; There is no "lossless JPEG" in canvas. If you need bit-accuracy, the format is PNG or WebP (lossless) — not a higher quality number.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The part that isn't about quality at all
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;toBlob()&lt;/code&gt; does not copy your file. It encodes the RGBA pixels currently on the canvas. Three consequences people trip over:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata is gone.&lt;/strong&gt; The canvas holds pixels, not a container. EXIF, GPS, XMP and the ICC profile do not survive &lt;code&gt;drawImage&lt;/code&gt; → &lt;code&gt;toBlob&lt;/code&gt;. Sometimes that's the feature (stripping location data before publishing); sometimes it silently breaks a colour-managed workflow. Either way it is a side effect, not a setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alpha survives for PNG and WebP, and cannot survive JPEG.&lt;/strong&gt; JPEG has no alpha channel. Transparent regions come out black unless you paint something first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're exporting SVG to JPEG and your logo's transparent background turned into a black box, this line is the fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Animation is flattened.&lt;/strong&gt; Canvas is a single raster. Draw an animated GIF, APNG or animated WebP into it and you get one frame — whatever frame the image happens to be showing. There is no frame count in &lt;code&gt;toBlob&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this bites hardest: SVG → PNG
&lt;/h2&gt;

&lt;p&gt;This is the exact path an SVG-to-PNG converter takes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;serialise the SVG and load it into an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;drawImage()&lt;/code&gt; it onto a canvas at the pixel size you want,&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;canvas.toBlob(cb, 'image/png')&lt;/code&gt; and hand the result to the user.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 is the PNG row of the table above. The quality argument is quietly discarded.&lt;/p&gt;

&lt;p&gt;So when you're looking for a "higher quality" PNG export of a vector file, the quality field is the wrong control — it does nothing, in any browser. The only lever that changes the output is &lt;strong&gt;how many pixels you rasterise to&lt;/strong&gt;. A 24×24 icon exported at 512×512 is 512×512 real pixels; the SVG's own &lt;code&gt;width&lt;/code&gt;/&lt;code&gt;height&lt;/code&gt; attributes don't cap it, and a file with only a &lt;code&gt;viewBox&lt;/code&gt; has no intrinsic size at all (browsers fall back to 300×150 when they must).&lt;/p&gt;

&lt;p&gt;I keep a browser-only converter at &lt;a href="https://imgloft.com/svg-to-png" rel="noopener noreferrer"&gt;imgloft.com/svg-to-png&lt;/a&gt; where the only input that matters is the output size, precisely because that's the only one that does anything. If you want the long version of the SVG rasterisation pitfalls — external resources silently skipped, webfonts that never load, &lt;code&gt;currentColor&lt;/code&gt; resolving to black — they're written up in the &lt;a href="https://imgloft.com/free-online-svg-to-png-converter" rel="noopener noreferrer"&gt;SVG to PNG guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it in twenty lines
&lt;/h2&gt;

&lt;p&gt;Paste this in any browser console. It rebuilds the test canvas and prints the same table:&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;800&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createLinearGradient&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="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addColorStop&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#1b3a6b&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addColorStop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#c94f2a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addColorStop&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#f2e6c9&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;6000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`rgba(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;255&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="s2"&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;255&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="s2"&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;255&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="s2"&gt;,0.35)`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;1190&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;790&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#111&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;72px monospace&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;fillText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MEASURED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&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;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;r&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="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="o"&gt;=&amp;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="s1"&gt;png  0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enc&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="mf"&gt;0.1&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="s1"&gt;png  1.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enc&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="mf"&gt;1.0&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="s1"&gt;jpeg 0.8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.8&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="s1"&gt;webp 0.8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enc&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/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two PNG numbers will match. They always match.&lt;/p&gt;

&lt;h2&gt;
  
  
  One gotcha if you wire this to a button
&lt;/h2&gt;

&lt;p&gt;My first attempt at measuring this was wrong, and the mistake is easy to make. The convert button in a typical pipeline is disabled while the async encode is in flight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="nx"&gt;btn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&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="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A disabled button does not dispatch &lt;code&gt;click&lt;/code&gt;. So a script that clicks, changes the quality, and clicks again gets &lt;strong&gt;the first result twice&lt;/strong&gt; — and you conclude the quality setting is ignored. I nearly published "WebP ignores quality too" on that basis. Reload the page between measurements, or poll until &lt;code&gt;btn.disabled === false&lt;/code&gt; before clicking again.&lt;/p&gt;

&lt;p&gt;Same trap catches users, not just testers: double-clicking a convert button mid-encode does nothing at all, which is usually the desired behaviour and occasionally a silent dropped job.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PNG&lt;/strong&gt;: quality is ignored — byte-identical output. Resize, quantise, or switch format.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JPEG&lt;/strong&gt;: quality works, hard. Watch out for the transparent-to-black default and for q = 1.0 still being lossy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebP&lt;/strong&gt;: quality works, and it beat JPEG at every setting on my test pixels, alpha included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything&lt;/strong&gt;: &lt;code&gt;toBlob&lt;/code&gt; re-encodes pixels, so metadata is dropped, animation is flattened, and the only "more quality" lever for vector sources is more pixels.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build tools like these, measure the numbers yourself before you write them on a landing page. A quality slider that does nothing is a much worse look than no slider at all.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>canvas</category>
      <category>performance</category>
    </item>
    <item>
      <title>Base64 decoding: padding, UTF-8, and the atob() trap</title>
      <dc:creator>sharefun2023</dc:creator>
      <pubDate>Mon, 21 Sep 2026 12:06:14 +0000</pubDate>
      <link>https://dev.to/sharefun2023/base64-decoding-padding-utf-8-and-the-atob-trap-11hf</link>
      <guid>https://dev.to/sharefun2023/base64-decoding-padding-utf-8-and-the-atob-trap-11hf</guid>
      <description>&lt;p&gt;Most Base64 bugs I have debugged were not crypto bugs. They were text-encoding bugs wearing a Base64 costume — or decoders being far more permissive than anyone assumed. Here are the five that keep showing up, each one measured rather than remembered.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;atob()&lt;/code&gt; does not return text
&lt;/h2&gt;

&lt;p&gt;Base64 encodes &lt;em&gt;bytes&lt;/em&gt;, not characters. &lt;code&gt;atob()&lt;/code&gt; returns a "binary string": one character per byte, char codes 0–255. Non-ASCII text (i.e. anything UTF-8) comes back as mojibake:&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;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5paH5Lu2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// "æä»¶"  ← not what you meant&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is one extra step, and it is always the same step:&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;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// "文件"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Newer engines are getting a native helper (&lt;code&gt;Uint8Array.fromBase64()&lt;/code&gt;), but the split is real: Chrome (v153) has it and Node 24 does not — I checked both on this machine, side by side. Until every runtime you target agrees, &lt;code&gt;TextDecoder&lt;/code&gt; is the form that works everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Padding is structure, not payload
&lt;/h2&gt;

&lt;p&gt;Base64 packs 3 bytes into 4 characters. When the byte count is not divisible by 3, &lt;code&gt;=&lt;/code&gt; fills out the last group. Two useful consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Browsers accept missing padding: &lt;code&gt;atob('aGVsbG8')&lt;/code&gt; returns &lt;code&gt;'hello'&lt;/code&gt; without complaint.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;length % 4 === 1&lt;/code&gt; is impossible. If you compute that, the string was truncated somewhere upstream — not "recovered" by adding &lt;code&gt;=&lt;/code&gt;. &lt;code&gt;atob('a')&lt;/code&gt; throws &lt;code&gt;InvalidCharacterError&lt;/code&gt;, which is the correct behaviour being politely demonstrated.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. URL-safe Base64 is a different alphabet
&lt;/h2&gt;

&lt;p&gt;JWT segments, S3 presigned URLs, anything passed through a query string: &lt;code&gt;+&lt;/code&gt; becomes &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt; becomes &lt;code&gt;_&lt;/code&gt;, and the &lt;code&gt;=&lt;/code&gt; padding is usually stripped. &lt;code&gt;atob()&lt;/code&gt; rejects those two characters outright:&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;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a-b_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// InvalidCharacterError&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So convert the alphabet &lt;em&gt;before&lt;/em&gt; decoding, and re-add the padding:&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;b64urlToText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&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;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Node's decoder never complains, and that is the trap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Buffer.from(s, 'base64')&lt;/code&gt; is deliberately lenient. It ignores characters outside the alphabet, it ignores truncation, and it never throws. Measured on Node 24:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Buffer&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aGVs*bG8=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// 'hello'  ← the * was silently dropped&lt;/span&gt;
&lt;span class="nx"&gt;Buffer&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;aGVsb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// 'hel'    ← truncated input, no error&lt;/span&gt;
&lt;span class="nx"&gt;Buffer&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a-b_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;              &lt;span class="c1"&gt;// 'k\ufffd\ufffd' ← URL-safe chars, decoded as garbage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means "it decoded fine in Node" proves nothing at all: you can be handed a mangled string, get a plausible-looking result, and only find out much later. If the input can come from anywhere you do not control, validate before decoding:&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="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9+&lt;/span&gt;&lt;span class="se"&gt;/]&lt;/span&gt;&lt;span class="sr"&gt;*=&lt;/span&gt;&lt;span class="se"&gt;{0,2}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not valid Base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Whitespace is a non-issue (PEM, MIME, emails)
&lt;/h2&gt;

&lt;p&gt;Both browser &lt;code&gt;atob()&lt;/code&gt; and Node strip ASCII whitespace, so line-wrapped PEM bodies and MIME blobs decode as-is: &lt;code&gt;atob('aGVs\n bG8=')&lt;/code&gt; → &lt;code&gt;'hello'&lt;/code&gt;. The classic failure is never whitespace — it is everything in section 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the "free online decoder" pattern actually leaks
&lt;/h2&gt;

&lt;p&gt;Look at what gets pasted into a Base64 decoder. It is rarely a toy string: it is a session cookie, a signed token, an auth header copied out of a log line. If it is a JWT, the payload is a live credential until it expires, and it has just been POSTed to someone's server. That is also why token decoders make an attractive target — their request logs are full of credentials that still work.&lt;/p&gt;

&lt;p&gt;Browser devtools will decode for you, but the fiddly cases (URL-safe input, invalid bytes, known-truncation, a hex view of the decoded bytes) are what made me keep a decoder open in a browser tab. Mine is at &lt;a href="https://23232322.xyz/base64-decode" rel="noopener noreferrer"&gt;23232322.xyz/base64-decode&lt;/a&gt; — client-side only, nothing uploaded, and it explains &lt;em&gt;why&lt;/em&gt; an input is rejected instead of just failing. The encoder pair is at &lt;a href="https://23232322.xyz/base64-encode" rel="noopener noreferrer"&gt;base64-encode&lt;/a&gt;, and if you are pasting actual tokens, the one that verifies signatures (HS256/384/512 secrets and RS/ES256–512 public keys) is the &lt;a href="https://23232322.xyz/tools/jwt-debugger" rel="noopener noreferrer"&gt;JWT decoder &amp;amp; debugger&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;æä»¶&lt;/code&gt; instead of real text&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;atob()&lt;/code&gt; returns bytes-as-chars&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new TextDecoder('utf-8').decode(Uint8Array.from(atob(s), c =&amp;gt; c.charCodeAt(0)))&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;InvalidCharacterError&lt;/code&gt; on &lt;code&gt;-&lt;/code&gt; / &lt;code&gt;_&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;URL-safe alphabet&lt;/td&gt;
&lt;td&gt;swap &lt;code&gt;-_&lt;/code&gt; → &lt;code&gt;+/&lt;/code&gt;, re-pad&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throws on a 5-char segment&lt;/td&gt;
&lt;td&gt;&lt;code&gt;length % 4 === 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;input is truncated; fix upstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node decodes garbage without error&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Buffer.from&lt;/code&gt; silently drops junk&lt;/td&gt;
&lt;td&gt;regex-validate first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Line-wrapped PEM block&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;nothing to do, whitespace is ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>base64</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>I Counted How Many Free Dev Tools Quietly Upload Your Data. Then I Built 80 That Don't.</title>
      <dc:creator>sharefun2023</dc:creator>
      <pubDate>Sun, 13 Sep 2026 12:05:30 +0000</pubDate>
      <link>https://dev.to/sharefun2023/i-counted-how-many-free-dev-tools-quietly-upload-your-data-then-i-built-80-that-dont-d89</link>
      <guid>https://dev.to/sharefun2023/i-counted-how-many-free-dev-tools-quietly-upload-your-data-then-i-built-80-that-dont-d89</guid>
      <description>&lt;p&gt;Paste a JWT into a "free online JWT debugger." Paste a config file into a "free JSON formatter."&lt;/p&gt;

&lt;p&gt;Then open DevTools, hit the Network tab, and watch it POST your payload to &lt;code&gt;api.something-saas.com/v1/parse&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I got annoyed enough to check. Of the first dozen "free" tools I tried, &lt;strong&gt;eight&lt;/strong&gt; shipped data off the device. Some had a privacy policy. Some had a privacy policy that said "we may share data with partners." Your production tokens, in a partner's S3 bucket.&lt;/p&gt;

&lt;p&gt;Most of them weren't even doing it to be evil. They just built a Next.js app, needed the logic somewhere, and the server was already there. Formatter on the server. Base64 decode on the server. For a 3-line &lt;code&gt;JSON.stringify&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually costs you
&lt;/h2&gt;

&lt;p&gt;It's not just privacy theater. Server-side tooling has real costs you pay every day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; Round-trip to a region that isn't yours, on a connection that isn't yours. My regex tester took ~400ms to say "no match."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits and paywalls.&lt;/strong&gt; Free tier hits you at 10 operations. The tool that was "free" is now a $9/mo subscription for &lt;code&gt;btoa&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Availability.&lt;/strong&gt; Their server goes down, your tool goes down. Great during an incident, when you actually need to decode something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline.&lt;/strong&gt; On a plane, on a locked-down corporate network, the tool is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those problems disappears if the code runs in the browser tab you already have open.&lt;/p&gt;

&lt;h2&gt;
  
  
  What client-side actually means
&lt;/h2&gt;

&lt;p&gt;There is no API. There is no fetch to a backend. &lt;code&gt;Network&lt;/code&gt; stays empty except for the static assets. Concretely:&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;// Base64, no server. 2 lines and no data leaves the tab.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/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="c1"&gt;// JWT verify with a real signature check, in-page.&lt;/span&gt;
&lt;span class="c1"&gt;// WebCrypto handles HS256/384/512 and RS/ES via importKey.&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;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;importKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;raw&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verify&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;+&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;HMAC&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;h&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;p&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole "backend." The &lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt; is the API.&lt;/p&gt;

&lt;p&gt;The interesting cases are where people assume you &lt;em&gt;need&lt;/em&gt; a server:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Why people think it needs a server&lt;/th&gt;
&lt;th&gt;How it's actually done client-side&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQL formatting&lt;/td&gt;
&lt;td&gt;"Parsing is hard"&lt;/td&gt;
&lt;td&gt;Tokenizer + formatter in a Web Worker — keeps the main thread at 60fps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image conversion&lt;/td&gt;
&lt;td&gt;"Encoding is server work"&lt;/td&gt;
&lt;td&gt;Canvas + &lt;code&gt;toBlob('image/webp')&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG to PNG&lt;/td&gt;
&lt;td&gt;"Rasterization needs a service"&lt;/td&gt;
&lt;td&gt;Draw the SVG into a canvas via a data-URI image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex testing&lt;/td&gt;
&lt;td&gt;"Sandboxing"&lt;/td&gt;
&lt;td&gt;It's a regex. &lt;code&gt;RegExp&lt;/code&gt; has been in the engine forever&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hashing (SHA/MD5)&lt;/td&gt;
&lt;td&gt;"Performance"&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;crypto.subtle&lt;/code&gt;, native speed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;WebCrypto also gives you something the SaaS tools don't: &lt;strong&gt;the key never crosses the wire.&lt;/strong&gt; A server-side JWT verifier has to receive your signing secret to check a signature. That's a much bigger deal than the payload leak.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://23232322.xyz" rel="noopener noreferrer"&gt;23232322.xyz&lt;/a&gt; — 80+ tools, one page each, no build step. JSON formatter and validator, Base64 encode/decode, regex tester, JWT debugger with real HS256/384/512 and RS/ES256-512 verification, hash generators, UUID, URL encode/decode, diff checker, HTML minifier, and a bunch more.&lt;/p&gt;

&lt;p&gt;No account. No upload. No analytics on your input. Source for each page is plain HTML + vanilla JS, so you can read it and confirm nothing is being sent anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check any tool yourself in 20 seconds
&lt;/h2&gt;

&lt;p&gt;Before you trust any "free online X":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open DevTools → &lt;strong&gt;Network&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Filter to &lt;strong&gt;Fetch/XHR&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Use the tool with obvious dummy data (&lt;code&gt;{"test":1}&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;If a request appears with your input in the body — that data left your machine.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do that to my tools too. It's the point.&lt;/p&gt;

&lt;p&gt;If a tool can't be written this way, it's because it needs a real backend. Most of them don't. They just needed somewhere to put the code.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The whole set is at &lt;a href="https://23232322.xyz" rel="noopener noreferrer"&gt;23232322.xyz&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>tools</category>
    </item>
    <item>
      <title>Why Your SVG-to-PNG Conversion Looks Wrong (And How to Fix It in the Browser)</title>
      <dc:creator>sharefun2023</dc:creator>
      <pubDate>Thu, 10 Sep 2026 12:34:26 +0000</pubDate>
      <link>https://dev.to/sharefun2023/why-your-svg-to-png-conversion-looks-wrong-and-how-to-fix-it-in-the-browser-1k8n</link>
      <guid>https://dev.to/sharefun2023/why-your-svg-to-png-conversion-looks-wrong-and-how-to-fix-it-in-the-browser-1k8n</guid>
      <description>&lt;h1&gt;
  
  
  Why Your SVG-to-PNG Conversion Looks Wrong (And How to Fix It in the Browser)
&lt;/h1&gt;

&lt;p&gt;You export an icon to PNG and it comes back with the wrong font, a black background instead of transparency, or soft, blurry edges. Nine times out of ten the bug is not in your export code — it's in the assumption that a browser rasterizes SVG the same way a design tool does.&lt;/p&gt;

&lt;p&gt;Converting SVG to PNG in the browser is really just three steps: turn the markup into an image, draw that image onto a canvas, and read the canvas back as a PNG. The canvas is doing all the work. Everything that looks wrong comes from what the browser &lt;em&gt;refuses&lt;/em&gt; to load while doing it.&lt;/p&gt;

&lt;p&gt;Here is the full pipeline plus the seven failure modes I hit while building a client-side converter with no server, no uploads, and no dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;svgToPng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svgText&lt;/span&gt;&lt;span class="p"&gt;,&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;background&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="o"&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;// 1. Parse and normalise the SVG&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&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;DOMParser&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;parseFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svgText&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/svg+xml&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;svg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&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;svg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;parsererror&lt;/span&gt;&lt;span class="dl"&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="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid SVG&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Resolve the intrinsic size&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;vb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;viewBox&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="p"&gt;[,&lt;/span&gt; &lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vb&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 0 300 150&lt;/span&gt;&lt;span class="dl"&gt;'&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="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&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;svg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;width&lt;/span&gt;&lt;span class="dl"&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;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&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;svg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;height&lt;/span&gt;&lt;span class="dl"&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;150&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Force explicit pixel dimensions (see pitfall 3)&lt;/span&gt;
  &lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;width&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;height&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;xmlns&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://www.w3.org/2000/svg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Serialise to a blob URL the &amp;lt;img&amp;gt; can load&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;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;XMLSerializer&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;serializeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;)],&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/svg+xml;charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&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;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="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&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;loadImage&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="c1"&gt;// 4. Draw at scale for a crisp result&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;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="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;h&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;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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;background&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 5. Export&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&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="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Canvas is tainted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&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="nf"&gt;revokeObjectURL&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="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&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="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="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&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;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onload&lt;/span&gt; &lt;span class="o"&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="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&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="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SVG failed to rasterise&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&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;Roughly 40 lines, and it works — for a subset of SVG. The rest of this post is about that subset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1: external resources are silently blocked
&lt;/h2&gt;

&lt;p&gt;This is the big one. When an SVG is loaded through an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, the browser treats it as an &lt;strong&gt;image&lt;/strong&gt;, not a document. Images do not get to fetch subresources. So these all fail silently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;image href="logo.png"&amp;gt;&lt;/code&gt; pointing at a file or a URL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@font-face&lt;/code&gt; rules inside a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@import&lt;/code&gt; of a stylesheet&lt;/li&gt;
&lt;li&gt;anything behind &lt;code&gt;url(#…)&lt;/code&gt; that resolves to a network request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no error. No console warning. The element just renders as nothing, and you get a PNG with a hole in it. The fix is to inline everything first — convert referenced images to &lt;code&gt;data:&lt;/code&gt; URIs, and turn text into paths before export if the font matters (see next pitfall).&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: fonts fall back to whatever is installed
&lt;/h2&gt;

&lt;p&gt;Same root cause, different symptom. Your SVG says &lt;code&gt;font-family: "Inter"&lt;/code&gt;, the browser rasterizing it has never heard of Inter, and you get Helvetica or DejaVu instead. Text reflows, line breaks move, and a logo turns into something slightly wrong that nobody can name.&lt;/p&gt;

&lt;p&gt;Two reliable options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Convert text to paths in the source file.&lt;/strong&gt; The output is then font-independent everywhere, forever. This is what &lt;code&gt;text-to-path&lt;/code&gt; / &lt;code&gt;object-to-path&lt;/code&gt; does in your editor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use only fonts you can guarantee&lt;/strong&gt; — generic families (&lt;code&gt;sans-serif&lt;/code&gt;, &lt;code&gt;serif&lt;/code&gt;, &lt;code&gt;monospace&lt;/code&gt;) or system stacks. They render, just never the exact file you designed with.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you keep live text, expect the browser's metrics, not your design tool's metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: no width/height, no pixels
&lt;/h2&gt;

&lt;p&gt;An SVG with only a &lt;code&gt;viewBox&lt;/code&gt; has no intrinsic size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;svg&lt;/span&gt; &lt;span class="na"&gt;viewBox=&lt;/span&gt;&lt;span class="s"&gt;"0 0 24 24"&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.w3.org/2000/svg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's perfectly valid SVG, and Chrome will happily display it when sized by CSS. But when you load the same markup into an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt;, several browsers report &lt;code&gt;naturalWidth&lt;/code&gt; as &lt;code&gt;0&lt;/code&gt; — and &lt;code&gt;drawImage&lt;/code&gt; with a zero-sized source throws or draws nothing. The export "succeeds" and produces an empty or 1×1 PNG.&lt;/p&gt;

&lt;p&gt;Fix: always set explicit &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; attributes on the root &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; before serialising, derived from the &lt;code&gt;viewBox&lt;/code&gt;. That's the &lt;code&gt;svg.setAttribute('width', w)&lt;/code&gt; line above, and it's the single most common cause of "my converter only works on some files."&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 4: blurry output on retina
&lt;/h2&gt;

&lt;p&gt;A canvas has two sizes: its CSS size and its backing-store size. Only the backing store defines how many real pixels you get. If you set the canvas to 300×300 and draw a 300×300 icon into it, you get a 300×300 PNG — which is correct, and looks soft on a 2× display because there simply aren't enough pixels.&lt;/p&gt;

&lt;p&gt;Set the backing store bigger and scale the drawing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;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="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;w&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;// scale = 2 for 2x&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="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;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;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;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because SVG is resolution-independent, you can go as high as you like — &lt;code&gt;scale = 4&lt;/code&gt; on a 24px icon gives you 96×96 real pixels. Just remember the dimensions you multiply here are the &lt;em&gt;output&lt;/em&gt; pixel dimensions; a 4000px export of a complex icon is a 4000px PNG, not a free lunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 5: "transparent" isn't
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;canvas&lt;/code&gt; starts fully transparent, so &lt;code&gt;toBlob('image/png')&lt;/code&gt; gives you real alpha by default. The surprise is the other direction: designers export SVGs with a white &lt;code&gt;&amp;lt;rect&amp;gt;&lt;/code&gt; covering the artboard, so your "transparent" PNG arrives with a white box painted over the background. Nothing to fix in code — it's in the source file.&lt;/p&gt;

&lt;p&gt;If you &lt;em&gt;do&lt;/em&gt; want a flat background (say for a JPG or for email), paint it before drawing, since &lt;code&gt;drawImage&lt;/code&gt; composites rather than replaces:&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="nx"&gt;background&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;background&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="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;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pitfall 6: filters and &lt;code&gt;foreignObject&lt;/code&gt; are renderer-specific
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;feGaussianBlur&lt;/code&gt;, &lt;code&gt;feTurbulence&lt;/code&gt;, &lt;code&gt;mix-blend-mode&lt;/code&gt;, and &lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt; are the parts of SVG that every engine implements a little differently. A blur that looks right in Chrome can look banded in Firefox and glossier in Safari. &lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt; — the trick for rendering HTML inside SVG — is the least portable feature in the spec, and it's also the fastest way to get a canvas that won't export (next pitfall).&lt;/p&gt;

&lt;p&gt;If your target is a PNG that looks identical for everyone, flatten these effects in the source file or accept per-browser drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 7: the tainted canvas
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;canvas.toBlob()&lt;/code&gt; throws &lt;code&gt;SecurityError&lt;/code&gt; when the canvas is "tainted" — meaning something cross-origin was drawn into it. With pure SVG-as-image this is mostly moot because external resources are blocked anyway, but it comes straight back when you compose a design: draw a user's cross-origin photo onto the canvas, then try to export, and the export dies even though the pixels are right there on screen.&lt;/p&gt;

&lt;p&gt;Two ways out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Serve the images with permissive CORS headers and set &lt;code&gt;img.crossOrigin = 'anonymous'&lt;/code&gt; &lt;strong&gt;before&lt;/strong&gt; setting &lt;code&gt;src&lt;/code&gt;. (Order matters; setting it after triggers a second, uncached request or an outright failure.)&lt;/li&gt;
&lt;li&gt;Do the compositing server-side, which is exactly the dependency a client-side tool is trying to avoid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is also why the classic "paste a URL and convert" trick fails: the remote SVG has to be fetched with CORS enabled, or the browser blocklists it before a single pixel is drawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Before you blame your code, check the source file:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Everything inlined — images as &lt;code&gt;data:&lt;/code&gt; URIs, fonts as paths or generic families.&lt;/li&gt;
&lt;li&gt;Explicit &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; on the root &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt;, matching the &lt;code&gt;viewBox&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;xmlns="http://www.w3.org/2000/svg"&lt;/code&gt; present after serialisation.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;currentColor&lt;/code&gt; and no &lt;code&gt;var(--…)&lt;/code&gt; — outside a page context there is nothing to inherit from, so they resolve to defaults (usually black).&lt;/li&gt;
&lt;li&gt;No page CSS assumptions — styles must live inside the SVG's own &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; or as attributes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And in your code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Backing store scaled up for retina, not just CSS size.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;URL.revokeObjectURL&lt;/code&gt; in a &lt;code&gt;finally&lt;/code&gt; block, or you leak the blob on every conversion.&lt;/li&gt;
&lt;li&gt;Paint a background &lt;em&gt;before&lt;/em&gt; &lt;code&gt;drawImage&lt;/code&gt; if the format has no alpha channel.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  I built one of these
&lt;/h2&gt;

&lt;p&gt;These are the exact edge cases I hit while building &lt;strong&gt;&lt;a href="https://imgloft.com" rel="noopener noreferrer"&gt;imgloft.com&lt;/a&gt;&lt;/strong&gt; — a free browser-side image toolset for SVG→PNG, PNG compression, resizing and cropping. It does the rasterisation on your device with the pipeline above, so nothing is uploaded and there's no queue to wait in. If you just want to convert a file rather than debug one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://imgloft.com/svg-to-png" rel="noopener noreferrer"&gt;SVG to PNG converter&lt;/a&gt;&lt;/strong&gt; — scale 1×–4×, transparent or flat background&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://imgloft.com/free-online-svg-to-png-converter" rel="noopener noreferrer"&gt;The longer guide version of this post&lt;/a&gt;&lt;/strong&gt; — including a comparison table of browser-side vs server-side conversion&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've hit a rasterisation bug that isn't on this list, I want to hear it — drop it in the comments and I'll reproduce it.&lt;/p&gt;

</description>
      <category>svg</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Built a Free SVG to PNG Converter in 1 Hour</title>
      <dc:creator>sharefun2023</dc:creator>
      <pubDate>Tue, 04 Aug 2026 08:05:54 +0000</pubDate>
      <link>https://dev.to/sharefun2023/how-i-built-a-free-svg-to-png-converter-in-1-hour-4ci9</link>
      <guid>https://dev.to/sharefun2023/how-i-built-a-free-svg-to-png-converter-in-1-hour-4ci9</guid>
      <description>&lt;p&gt;I needed a quick way to convert SVG icons to PNG for a project. Every online converter I found required uploading files to a server — slow, privacy-invasive, and overkill for a 2KB icon.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://imgloft.com" rel="noopener noreferrer"&gt;imgloft.com&lt;/a&gt;&lt;/strong&gt; — a free, client-side SVG to PNG converter that works entirely in your browser. No uploads, no server, 100% private.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Most online SVG-to-PNG tools follow the same pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Upload your SVG to their server&lt;/li&gt;
&lt;li&gt;Server runs a conversion library (ImageMagick, librsvg, etc.)&lt;/li&gt;
&lt;li&gt;Download the result&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is fine for occasional use, but it breaks down when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're working with sensitive design assets&lt;/li&gt;
&lt;li&gt;The file is large and upload takes forever&lt;/li&gt;
&lt;li&gt;You're offline or behind a restrictive firewall&lt;/li&gt;
&lt;li&gt;You just want to convert a tiny icon without waiting&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 1-Hour Solution
&lt;/h2&gt;

&lt;p&gt;The entire converter is built with three browser APIs:&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;// 1. Read the SVG file&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&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;FileReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;svgText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Draw it on a canvas at the desired resolution&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&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;Image&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;svgBlob&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;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;svgText&lt;/span&gt;&lt;span class="p"&gt;],&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/svg+xml&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;svgBlob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onload&lt;/span&gt; &lt;span class="o"&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="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&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;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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;img&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="c1"&gt;// 3. Export as PNG&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;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// Trigger download&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="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readAsText&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Three APIs, zero dependencies, zero server cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Only Took 1 Hour
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No backend.&lt;/strong&gt; The entire tool runs in the browser using the Canvas API. No Express, no ImageMagick, no Docker container. Just a static HTML file served from Cloudflare Pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No auth.&lt;/strong&gt; Users don't need accounts — they come, convert, and leave. No database, no user management, no session tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No framework.&lt;/strong&gt; Plain HTML, vanilla JavaScript, and a few lines of CSS. No React, no build step, no node_modules.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Client-side is underrated.&lt;/strong&gt; The browser can do way more than we give it credit for. SVG rendering, image encoding, even basic image manipulation — all built in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship the MVP.&lt;/strong&gt; I could have spent weeks adding batch conversion, format options, color filters. Instead I shipped the core feature in an afternoon and let users tell me what they need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Free tools attract real users.&lt;/strong&gt; Within days of going live, Google started indexing the site. People are searching for "svg to png converter" — 40,000 times a month. The demand is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://imgloft.com" rel="noopener noreferrer"&gt;imgloft.com&lt;/a&gt;&lt;/strong&gt; — drag any SVG, get a PNG instantly. Also includes compress, resize, and crop tools.&lt;/p&gt;

&lt;p&gt;If you're thinking about building a micro-tool site, just do it. Ship the simplest version first. The browser is your backend.&lt;/p&gt;

</description>
      <category>svg</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building a Client-Side SQL Formatter with Vanilla JS and Web Workers</title>
      <dc:creator>sharefun2023</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:35:46 +0000</pubDate>
      <link>https://dev.to/sharefun2023/building-a-client-side-sql-formatter-with-vanilla-js-and-web-workers-888</link>
      <guid>https://dev.to/sharefun2023/building-a-client-side-sql-formatter-with-vanilla-js-and-web-workers-888</guid>
      <description>&lt;p&gt;Most SQL formatters process queries server-side. That means every time you click "Format", your SQL gets sent to a random backend — with no guarantee of what happens to it.&lt;/p&gt;

&lt;p&gt;I wanted something different: &lt;strong&gt;a formatter where your data never leaves your browser.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://sqlformat.io" rel="noopener noreferrer"&gt;sqlformat.io&lt;/a&gt;&lt;/strong&gt; is built with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vanilla JavaScript&lt;/strong&gt; — no framework, no build step&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/sql-formatter-org/sql-formatter" rel="noopener noreferrer"&gt;sql-formatter&lt;/a&gt;&lt;/strong&gt; — the formatting engine&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web Workers&lt;/strong&gt; — formatting runs off the main thread so the UI stays snappy even on 1000+ line queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; — zero-cost hosting with global edge caching&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User pastes SQL
  ↓
Main thread sends to Web Worker
  ↓
Worker calls sql-formatter with dialect-specific options
  ↓
Formatted SQL sent back to main thread
  ↓
Rendered with syntax highlighting (no library needed — just regex + &amp;lt;span&amp;gt;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key insight:&lt;/strong&gt; the sql-formatter library alone is ~50KB gzipped. Running it in a Web Worker means the main thread never blocks, and the user can keep typing while large queries format in the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why No Server?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy&lt;/strong&gt; — zero bytes leave the user browser&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline&lt;/strong&gt; — works after the page loads, no internet needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale&lt;/strong&gt; — no server costs, no rate limits, no API keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed&lt;/strong&gt; — no round trip, instant formatting&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Web Workers are underrated&lt;/strong&gt; — they turn a potentially laggy text processor into a buttery-smooth UX&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL dialects are deceptively complex&lt;/strong&gt; — MySQL vs PostgreSQL vs BigQuery all have different quoting rules, and the formatter needs to handle inline comments and dollar-quoted strings that span multiple lines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regex-based syntax highlighting works surprisingly well&lt;/strong&gt; for SQL — you do not need a parser, just a few well-placed regex patterns for keywords, strings, numbers, and comments&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Try it: &lt;a href="https://sqlformat.io" rel="noopener noreferrer"&gt;sqlformat.io&lt;/a&gt;&lt;br&gt;&lt;br&gt;
Source: &lt;a href="https://github.com/sharefun2023/sqlformat.io" rel="noopener noreferrer"&gt;github.com/sharefun2023/sqlformat.io&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Got Tired of Slow SQL Formatters That Upload My Queries — So I Built a Free One</title>
      <dc:creator>sharefun2023</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:35:08 +0000</pubDate>
      <link>https://dev.to/sharefun2023/i-got-tired-of-slow-sql-formatters-that-upload-my-queries-so-i-built-a-free-one-129o</link>
      <guid>https://dev.to/sharefun2023/i-got-tired-of-slow-sql-formatters-that-upload-my-queries-so-i-built-a-free-one-129o</guid>
      <description>&lt;p&gt;I write a lot of SQL every day. And every online formatter I tried had the same problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uploads queries to their server&lt;/strong&gt; — privacy nightmare when you are working with production schema&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tiny text area&lt;/strong&gt; — cannot handle 500+ line queries without lag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 ads&lt;/strong&gt; before you even see the formatted output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://sqlformat.io" rel="noopener noreferrer"&gt;sqlformat.io&lt;/a&gt;&lt;/strong&gt; — a no-BS SQL formatter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client-side only&lt;/strong&gt; — everything runs in YOUR browser via Web Worker. Zero bytes leave your device&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;15+ SQL dialects&lt;/strong&gt; — MySQL, PostgreSQL, SQLite, BigQuery, Snowflake, T-SQL, and more&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No signup, no ads, no upload&lt;/strong&gt; — ever&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instant&lt;/strong&gt; — paste, click Format, done&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I Built It
&lt;/h2&gt;

&lt;p&gt;I got tired of copy-pasting production queries into random websites and wondering where they ended up. With sqlformat.io, you can disconnect your internet after the page loads and keep formatting offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extra Tools Included
&lt;/h2&gt;

&lt;p&gt;It is not just a formatter — the site has several free SQL tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Diff&lt;/strong&gt; — compare two SQL queries side by side&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minify/Compress&lt;/strong&gt; — strip whitespace for embedding in code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate&lt;/strong&gt; — syntax check across dialects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ER Diagram&lt;/strong&gt; — visualize table relationships&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL to JSON&lt;/strong&gt; — convert query results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data Generator&lt;/strong&gt; — generate test data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explain Visualizer&lt;/strong&gt; — MySQL/PostgreSQL EXPLAIN output as a tree&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;p&gt;Vanilla JS + &lt;a href="https://github.com/sql-formatter-org/sql-formatter" rel="noopener noreferrer"&gt;sql-formatter&lt;/a&gt; library, hosted on Cloudflare Pages. All formatting runs in a Web Worker so the UI stays responsive even on huge queries.&lt;/p&gt;




&lt;p&gt;Open source: &lt;a href="https://github.com/sharefun2023/sqlformat.io" rel="noopener noreferrer"&gt;github.com/sharefun2023/sqlformat.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Would love feedback from folks who format SQL daily!&lt;/p&gt;

</description>
      <category>sql</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
