<?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: Rushi Patel</title>
    <description>The latest articles on DEV Community by Rushi Patel (@signops).</description>
    <link>https://dev.to/signops</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%2F4198953%2F50c3efc2-e57c-4127-b3fe-5ce62234b188.png</url>
      <title>DEV Community: Rushi Patel</title>
      <link>https://dev.to/signops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/signops"/>
    <language>en</language>
    <item>
      <title>HTML to PDF with headless Chrome in Node.js, without Puppeteer</title>
      <dc:creator>Rushi Patel</dc:creator>
      <pubDate>Tue, 10 Nov 2026 23:12:21 +0000</pubDate>
      <link>https://dev.to/signops/html-to-pdf-with-headless-chrome-in-nodejs-without-puppeteer-2l4</link>
      <guid>https://dev.to/signops/html-to-pdf-with-headless-chrome-in-nodejs-without-puppeteer-2l4</guid>
      <description>&lt;p&gt;You can turn any HTML into a print-ready PDF with &lt;strong&gt;headless Chrome and about 60 lines of Node.js&lt;/strong&gt;, no Puppeteer and no npm packages: start Chrome with a debugging port, open its WebSocket, and send two DevTools Protocol commands, &lt;code&gt;Page.navigate&lt;/code&gt; and &lt;code&gt;Page.printToPDF&lt;/code&gt;. Here is the whole script, the CSS that makes the page size right, and four things that go wrong.&lt;/p&gt;

&lt;p&gt;We use exactly this on &lt;a href="https://triplli.com/" rel="noopener noreferrer"&gt;Triplli&lt;/a&gt;, our sister product for travel agencies, to make its free downloads: a blank and a filled &lt;a href="https://triplli.com/guides/itinerary-format-for-travel-agency/" rel="noopener noreferrer"&gt;itinerary format&lt;/a&gt; and a &lt;a href="https://triplli.com/guides/travel-agency-payment-receipt-format/" rel="noopener noreferrer"&gt;payment receipt format&lt;/a&gt;, each printed from the same HTML that the guide page shows. The document is drawn once, in HTML, and the PDF, the picture and the page can never disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Puppeteer
&lt;/h2&gt;

&lt;p&gt;Puppeteer is fine, and it downloads its own copy of Chrome on install. For a build script that prints a handful of documents from a Chrome already on the machine, the Chrome DevTools Protocol (CDP) directly is smaller and has nothing to keep up to date. Node.js 22 has a global &lt;code&gt;WebSocket&lt;/code&gt;, so there is no dependency at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: start Chrome with a debugging port
&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;spawn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"node:child_process"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;mkdtempSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rmSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"node:fs"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tmpdir&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"node:os"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"node:path"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;CHROME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;CHROME&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;9222&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mkdtempSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tmpdir&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="s2"&gt;"pdf-"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;chrome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CHROME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s2"&gt;"--headless=new"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"--disable-gpu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"--no-first-run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sb"&gt;`--remote-debugging-port=${port}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sb"&gt;`--user-data-dir=${profile}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"about:blank"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ignore"&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;A fresh &lt;code&gt;--user-data-dir&lt;/code&gt; per run keeps your own Chrome profile out of it, and a random port lets two runs work side by side. On Linux the binary is usually &lt;code&gt;google-chrome&lt;/code&gt; or &lt;code&gt;chromium&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: find the page and connect
&lt;/h2&gt;

&lt;p&gt;Chrome lists its open pages, each with a WebSocket URL, at &lt;code&gt;/json/list&lt;/code&gt;. It takes a moment to start, so poll:&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="n"&gt;sleep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ms&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;target&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="n"&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="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;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="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="n"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`http://127.0.0.1:${port}/json/list`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;"page"&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;catch&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;sleep&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="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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"chrome did not start"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;ws&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;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;webSocketDebuggerUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onopen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="n"&gt;id&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="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&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;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;evaluate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expression&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Runtime.evaluate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;expression&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;returnByValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;awaitPromise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every CDP command is a JSON message with an &lt;code&gt;id&lt;/code&gt;; the reply carries the same &lt;code&gt;id&lt;/code&gt;. That small &lt;code&gt;send&lt;/code&gt; is the whole client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: open the page and wait for fonts
&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;await&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Page.enable"&lt;/span&gt;&lt;span class="p"&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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Emulation.setDeviceMetricsOverride"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;860&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deviceScaleFactor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mobile&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="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Page.navigate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"http://localhost:4323/guides/itinerary-format/"&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;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&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;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"document.fonts.ready.then(() =&amp;gt; true)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Emulation.setDeviceMetricsOverride&lt;/code&gt; sets the layout width the PDF is printed from. Use it rather than &lt;code&gt;--window-size&lt;/code&gt;: headless Chrome's window will not go below about 500 pixels wide, which matters when you also screenshot phone layouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: keep only the document
&lt;/h2&gt;

&lt;p&gt;The guide page has a header, a footer and the article around the document. For the PDF we want the document alone, with its styles:&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;await&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`(() =&amp;gt; {
  const el = document.querySelector("#it-sample");
  const s = document.createElement("style");
  s.textContent = "@page { size: A4; margin: 12mm } html, body { background: #fff; margin: 0 }";
  document.head.append(s);
  document.body.replaceChildren(el);
  return true;
})()`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;replaceChildren&lt;/code&gt; moves the element into an otherwise empty body. The page's stylesheets stay in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, so it looks exactly as it did on the page. A blank version of the same document can sit on the page hidden (&lt;code&gt;display: none&lt;/code&gt;) and be printed the same way after setting &lt;code&gt;el.style.display = "block"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: print
&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;writeFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s2"&gt;"node:fs"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;r&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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Page.printToPDF"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;preferCSSPageSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;printBackground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"itinerary-format.pdf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&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="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"base64"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two options do most of the work:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Without it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;preferCSSPageSize: true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Uses &lt;code&gt;@page { size: A4 }&lt;/code&gt; from your CSS&lt;/td&gt;
&lt;td&gt;US Letter, 8.5 by 11 inches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;printBackground: true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prints background colours and shading&lt;/td&gt;
&lt;td&gt;Table headers and boxes print white&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To keep a block of the document from splitting across two pages, give it &lt;code&gt;break-inside: avoid&lt;/code&gt; in CSS. Chrome honours it in &lt;code&gt;printToPDF&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: a picture of it, for image search
&lt;/h2&gt;

&lt;p&gt;The same session can screenshot the element, cropped to its size, even when it is taller than the viewport:&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="n"&gt;rect&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;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`(() =&amp;gt; {
  const r = document.body.firstElementChild.getBoundingClientRect();
  return { x: r.x, y: r.y + scrollY, width: r.width, height: r.height };
})()`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;shot&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;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Page.captureScreenshot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;captureBeyondViewport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&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="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"itinerary-format.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&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="n"&gt;shot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"base64"&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;On Triplli the filled itinerary picture sits beside the page heading with &lt;code&gt;ImageObject&lt;/code&gt; markup, so the format can show up in image results as well as web results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things that go wrong
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fonts print as the fallback.&lt;/strong&gt; The PDF was printed before the web font arrived. Wait on &lt;code&gt;document.fonts.ready&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything prints on Letter.&lt;/strong&gt; &lt;code&gt;@page { size: A4 }&lt;/code&gt; does nothing until &lt;code&gt;preferCSSPageSize&lt;/code&gt; is true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animated content stays hidden.&lt;/strong&gt; Headless Chrome reports &lt;code&gt;prefers-reduced-motion&lt;/code&gt;, and elements that fade in on scroll may stay at opacity 0. Force them visible in the injected style, or call &lt;code&gt;Emulation.setEmulatedMedia&lt;/code&gt; with &lt;code&gt;prefers-reduced-motion: no-preference&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup crashes after a good run.&lt;/strong&gt; This one bit us: deleting the temporary profile on exit failed with &lt;code&gt;ENOTEMPTY&lt;/code&gt;, because Chrome was still writing to it while being killed. All the files were made; wrap the cleanup in &lt;code&gt;try { rmSync(profile, { recursive: true, force: true }) } catch {}&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;kill&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;rmSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;force&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole printer. The same approach makes &lt;a href="https://cabarobytriplli.triplli.com/duty-slip-format/" rel="noopener noreferrer"&gt;Cabaro&lt;/a&gt;'s duty slip for taxi operators, another sister product, in blank and filled form. For documents a server makes on demand, for every customer, a PDF library in the app is usually the better fit; for a build step that prints a few files from HTML you already have, headless Chrome and CDP are hard to beat.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://signops.dev/blog/html-to-pdf-headless-chrome-node/" rel="noopener noreferrer"&gt;signops.dev&lt;/a&gt;. Written with AI assistance and checked against the script it describes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Cloudflare D1 free tier limits: what counts as a row written</title>
      <dc:creator>Rushi Patel</dc:creator>
      <pubDate>Fri, 06 Nov 2026 20:30:15 +0000</pubDate>
      <link>https://dev.to/signops/cloudflare-d1-free-tier-limits-what-counts-as-a-row-written-d6e</link>
      <guid>https://dev.to/signops/cloudflare-d1-free-tier-limits-what-counts-as-a-row-written-d6e</guid>
      <description>&lt;p&gt;Cloudflare D1's free tier allows &lt;strong&gt;100,000 rows written and 5 million rows read a day&lt;/strong&gt;, counted across every database on the account, and resets at &lt;strong&gt;00:00 UTC&lt;/strong&gt;. When you go over, every query on every D1 database in the account fails until the reset. We found that out by taking three products down with one load test. Here is what the limits are, what counts as a row, and how to stay under them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits on Workers Free
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;Workers Free&lt;/th&gt;
&lt;th&gt;Workers Paid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rows read&lt;/td&gt;
&lt;td&gt;5 million a day&lt;/td&gt;
&lt;td&gt;25 billion a month included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rows written&lt;/td&gt;
&lt;td&gt;100,000 a day&lt;/td&gt;
&lt;td&gt;50 million a month included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;5 GB for the account&lt;/td&gt;
&lt;td&gt;5 GB included, up to 1 TB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database size&lt;/td&gt;
&lt;td&gt;500 MB&lt;/td&gt;
&lt;td&gt;10 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databases&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;100,000 included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queries per Worker invocation&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From Cloudflare's D1 pricing and limits pages, checked on 6 November 2026. Workers Paid is 5 US dollars a month. A &lt;code&gt;db.batch()&lt;/code&gt; call counts as one query toward the per-invocation limit, but every row inside it still counts toward reads and writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts as a row written
&lt;/h2&gt;

&lt;p&gt;This is where the 100,000 goes faster than you expect.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every inserted, updated or deleted row&lt;/strong&gt; counts. Inserting 10 rows is 10.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every index the write touches&lt;/strong&gt; adds a row. A table with two indexes on the columns you insert costs three rows per insert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deletes count.&lt;/strong&gt; Cleaning up after a test costs as much as the test did, and a retention job that deletes old rows every night spends writes every night.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema changes&lt;/strong&gt; (&lt;code&gt;CREATE&lt;/code&gt;, &lt;code&gt;ALTER&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;) can count as reads and writes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So one logical event is usually several rows. An email OTP in SignOps is about 8 over its life: the code row and its index, a daily counter, the check, then the deletion when it expires. At 8 rows each, 100,000 writes is about 12,000 codes a day, before anything else on the account writes a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened to us
&lt;/h2&gt;

&lt;p&gt;We run three products on one Cloudflare account: SignOps, &lt;a href="https://triplli.com/" rel="noopener noreferrer"&gt;Triplli&lt;/a&gt;, &lt;a href="https://triplli.com/travel-quotation-software/" rel="noopener noreferrer"&gt;quotation software for travel agencies&lt;/a&gt; in India, and &lt;a href="https://cabarobytriplli.triplli.com/" rel="noopener noreferrer"&gt;Cabaro&lt;/a&gt;, &lt;a href="https://cabarobytriplli.triplli.com/taxi-booking-software/" rel="noopener noreferrer"&gt;taxi booking software&lt;/a&gt; for cab operators. Each has its own D1 database. On the free plan they share one daily allowance.&lt;/p&gt;

&lt;p&gt;On 20 October 2026 we load tested SignOps push on production: 26,000 imported device tokens, three sends to 25,000 devices, then deleting the test devices. Each failed send wrote a row per device, and the cleanup wrote the same again. The account went past 100,000 rows written, and from then until 00:00 UTC, every write failed on all three products, including a travel agency quoting live customers in Triplli. Reads kept working, so pages loaded and every save failed.&lt;/p&gt;

&lt;p&gt;The only fix that day was to wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to stay under the limit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Work out the row cost before a test.&lt;/strong&gt; Rows written times (1 + indexes touched), plus the cleanup. 26,000 devices with two indexes is 78,000 rows to insert and 78,000 to delete: over the daily limit before a single send.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run volume tests locally.&lt;/strong&gt; &lt;code&gt;wrangler dev&lt;/code&gt; with a local D1 (&lt;code&gt;wrangler d1 migrations apply DB --local&lt;/code&gt;) has no daily limit. Keep production tests to a few hundred rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store counts, not events.&lt;/strong&gt; We replaced one row per failed device with one row per message, platform and reason, holding a count. A 25,000-device send with every token failing now writes a handful of rows instead of 25,000.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cap per customer, per day.&lt;/strong&gt; If customers' usage writes rows, give each organisation a daily ceiling well under the account's allowance, so one busy customer cannot use it all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give each product its own account.&lt;/strong&gt; The limit is per account. A second free Cloudflare account gives a second product its own 100,000 writes, and a spike in one cannot reach the other. It costs nothing but a login.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or pay 5 dollars.&lt;/strong&gt; Workers Paid includes 50 million rows written a month, about 1.6 million a day, and lifts the daily cutoff within minutes of upgrading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking how close you are
&lt;/h2&gt;

&lt;p&gt;The Cloudflare dashboard shows rows read and written per database under Storage and Databases, D1, Metrics. For the account total, add up every database, or query the GraphQL Analytics API's &lt;code&gt;d1AnalyticsAdaptiveGroups&lt;/code&gt; dataset for &lt;code&gt;rowsWritten&lt;/code&gt; grouped by day. Check it after any change that adds a table, an index or a scheduled job.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on the &lt;a href="https://signops.dev/blog/cloudflare-d1-free-tier-limits/" rel="noopener noreferrer"&gt;SignOps blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>database</category>
      <category>serverless</category>
      <category>webdev</category>
    </item>
    <item>
      <title>iOS silent push not working: content-available and background pushes</title>
      <dc:creator>Rushi Patel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 15:51:17 +0000</pubDate>
      <link>https://dev.to/signops/ios-silent-push-not-working-content-available-and-background-pushes-2dc2</link>
      <guid>https://dev.to/signops/ios-silent-push-not-working-content-available-and-background-pushes-2dc2</guid>
      <description>&lt;p&gt;A silent push, which Apple calls a background notification, has no alert, sound or badge. It wakes your app so it can fetch new content. Apple treats it as low priority and does not guarantee delivery, so "it worked once and then stopped" is often the system working as designed. This page goes through what must be right on the sender and in the app, and what the device itself decides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom to cause
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;Likely 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;Never arrives, alert pushes work&lt;/td&gt;
&lt;td&gt;Remote notifications background mode not ticked&lt;/td&gt;
&lt;td&gt;Signing and Capabilities &amp;gt; Background Modes &amp;gt; Remote notification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shows a banner instead of waking the app quietly&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;alert&lt;/code&gt;, &lt;code&gt;sound&lt;/code&gt; or &lt;code&gt;badge&lt;/code&gt; in &lt;code&gt;aps&lt;/code&gt; next to &lt;code&gt;content-available&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Send &lt;code&gt;aps&lt;/code&gt; with &lt;code&gt;content-available: 1&lt;/code&gt; and nothing else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;APNs returns &lt;code&gt;400&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apns-priority: 10&lt;/code&gt; with &lt;code&gt;apns-push-type: background&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Priority &lt;code&gt;5&lt;/code&gt; for background pushes, always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivered late, or in bursts&lt;/td&gt;
&lt;td&gt;Priority 5 pushes are sent according to the device's power state&lt;/td&gt;
&lt;td&gt;Expected; use an alert push for anything a person waits for&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arrives in testing, then stops&lt;/td&gt;
&lt;td&gt;Throttled: too many background pushes&lt;/td&gt;
&lt;td&gt;Two or three an hour at most&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing after the user swiped the app away&lt;/td&gt;
&lt;td&gt;The app was force quit&lt;/td&gt;
&lt;td&gt;Nothing to fix; it wakes again once the user opens it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only the last of several arrives&lt;/td&gt;
&lt;td&gt;The device holds only the newest background push&lt;/td&gt;
&lt;td&gt;Fetch everything new on each wake, not one item per push&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing on some phones&lt;/td&gt;
&lt;td&gt;Background App Refresh off, or the phone saving power&lt;/td&gt;
&lt;td&gt;Sync when the app comes to the foreground too&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The payload
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;aps&lt;/code&gt; dictionary must contain only &lt;code&gt;content-available&lt;/code&gt;. Your own keys go beside &lt;code&gt;aps&lt;/code&gt;, not inside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content-available"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"since"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1762300800"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any key that would make the device show something (&lt;code&gt;alert&lt;/code&gt;, &lt;code&gt;sound&lt;/code&gt;, &lt;code&gt;badge&lt;/code&gt;) turns it into a visible notification, which iOS handles differently. Keep the whole payload under 4 KB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--http2&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"authorization: bearer &lt;/span&gt;&lt;span class="nv"&gt;$JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apns-topic: com.example.app"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apns-push-type: background"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apns-priority: 5"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"aps":{"content-available":1},"kind":"inbox"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://api.sandbox.push.apple.com/3/device/&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;apns-push-type: background&lt;/code&gt;.&lt;/strong&gt; Required on watchOS, recommended everywhere else. Apple says that if the header does not match the payload, APNs may return an error, delay the push or drop it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;apns-priority: 5&lt;/code&gt;.&lt;/strong&gt; Apple: "Always use priority 5. Using priority 10 is an error." Priority 5 means APNs delivers based on the device's power, so the push can be grouped with others and arrive in a burst.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;apns-topic&lt;/code&gt;&lt;/strong&gt; is the bundle ID with nothing added.&lt;/li&gt;
&lt;li&gt;A sandbox token only works against &lt;code&gt;api.sandbox.push.apple.com&lt;/code&gt;. If the curl above fails, see &lt;a href="https://signops.dev/blog/apns-curl/" rel="noopener noreferrer"&gt;send a test push with curl&lt;/a&gt; and &lt;a href="https://signops.dev/blog/apns-error-codes/" rel="noopener noreferrer"&gt;APNs error codes&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Through FCM, the same three things go in the &lt;code&gt;apns&lt;/code&gt; block of the HTTP v1 message, and the message has no &lt;code&gt;notification&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FCM_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"inbox"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"apns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"apns-push-type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"background"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"apns-priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"payload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content-available"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The app
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Background mode.&lt;/strong&gt; In Signing and Capabilities, add Background Modes and tick Remote notifications. Without it, iOS does not wake the app for a background push.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register.&lt;/strong&gt; Call &lt;code&gt;registerForRemoteNotifications()&lt;/code&gt; on every launch. A background push does not need notification permission, but it does need a device token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle it in the right method.&lt;/strong&gt; On iOS the system calls &lt;code&gt;application(_:didReceiveRemoteNotification:fetchCompletionHandler:)&lt;/code&gt;. Your app has up to 30 seconds, and must call the completion handler or it is terminated.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;application&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;application&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UIApplication&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;didReceiveRemoteNotification&lt;/span&gt; &lt;span class="nv"&gt;userInfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;AnyHashable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;UIBackgroundFetchResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;changed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;Inbox&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;syncFromServer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;changed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;newData&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;noData&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failed&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;In SwiftUI apps this lives in a class you attach with &lt;code&gt;@UIApplicationDelegateAdaptor&lt;/code&gt;. Apple also notes that apps which use a lot of power handling remote notifications "may not always be woken up early" for later ones, so do the fetch and return.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the device decides
&lt;/h2&gt;

&lt;p&gt;These are not bugs, and no header changes them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Throttling.&lt;/strong&gt; Apple: the system may throttle background notifications "if the total number becomes excessive" and advises not sending "more than two or three per hour".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the newest is held.&lt;/strong&gt; While a delivery is delayed, a new background push replaces the older one. If the app is launched, the held one is delivered at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force quit.&lt;/strong&gt; If the user force quits the app, the held push is discarded, and the system does not launch the app in the background again until the user opens it or restarts the device. Test by pressing Home or locking the phone, not by swiping the app away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings and power.&lt;/strong&gt; Apple's delivery metrics list the device's power state and a person disabling background refresh among the things that decide delivery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So design for missed pushes: treat a background push as a hint to sync, and sync again whenever the app comes to the foreground.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Test on a real iPhone, from a build that is not attached to the Xcode debugger as well as one that is.&lt;/li&gt;
&lt;li&gt;Watch the device in the Console app on a Mac (filter by your app's process) to see the push arrive and the app wake.&lt;/li&gt;
&lt;li&gt;The Push Notifications Console at icloud.developer.apple.com sends test pushes and shows delivery metrics, including pushes APNs stored or discarded.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  With SignOps
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://signops.dev/push/" rel="noopener noreferrer"&gt;SignOps Push&lt;/a&gt; sends a background push when you give &lt;code&gt;data&lt;/code&gt; and no title or body. For an iPhone it then sets &lt;code&gt;apns-push-type: background&lt;/code&gt;, &lt;code&gt;apns-priority: 5&lt;/code&gt; and an &lt;code&gt;aps&lt;/code&gt; dictionary with only &lt;code&gt;content-available: 1&lt;/code&gt;, both when it sends to Apple directly and through FCM; your keys go beside &lt;code&gt;aps&lt;/code&gt;. A silent push cannot carry buttons.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.signops.dev/v1/push &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$SIGNOPS_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"content-type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"user_id":"usr_8f21c4","data":{"kind":"inbox"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app side is still yours: the background mode and the delegate method above. Setup is in the &lt;a href="https://signops.dev/guides/ios-push/" rel="noopener noreferrer"&gt;iOS push guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why is my iOS silent push not received?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The most common causes are a missing Remote notifications background mode, an aps dictionary with alert, sound or badge keys next to content-available, the wrong apns-push-type or apns-priority headers, an app the user force quit, Background App Refresh turned off, or Apple throttling because too many background pushes were sent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What headers does a background push need?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;apns-push-type set to background and apns-priority set to 5, with apns-topic set to the app's bundle ID. Apple says priority 10 with the background push type is an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many silent pushes can I send to an iPhone?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Apple does not give a fixed number. Its documentation says the system may throttle background notifications if there are too many, and advises sending no more than two or three per hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a silent push wake an app the user force quit?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. If the user force quits the app, the system discards the held background notification and does not launch the app again until the user opens it or restarts the device.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does my app have to handle a background push?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Up to 30 seconds. Call the completion handler of application(_:didReceiveRemoteNotification:fetchCompletionHandler:) as soon as you are done, or the app is terminated.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://developer.apple.com/documentation/usernotifications/pushing-background-updates-to-your-app" rel="noopener noreferrer"&gt;Apple: Pushing background updates to your App&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns" rel="noopener noreferrer"&gt;Apple: Sending notification requests to APNs&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application(_:didreceiveremotenotification:fetchcompletionhandler:)" rel="noopener noreferrer"&gt;Apple: application(_:didReceiveRemoteNotification:fetchCompletionHandler:)&lt;/a&gt;, &lt;a href="https://developer.apple.com/documentation/usernotifications/viewing-the-status-of-push-notifications-using-metrics-and-apns" rel="noopener noreferrer"&gt;Apple: Viewing the status of push notifications&lt;/a&gt;, &lt;a href="https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages" rel="noopener noreferrer"&gt;Firebase: FCM HTTP v1 message&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;First published on &lt;a href="https://signops.dev/blog/ios-silent-push-not-working/" rel="noopener noreferrer"&gt;signops.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>mobile</category>
      <category>firebase</category>
    </item>
    <item>
      <title>Email OTP vs SMS OTP: cost, delivery and security</title>
      <dc:creator>Rushi Patel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 03:41:14 +0000</pubDate>
      <link>https://dev.to/signops/email-otp-vs-sms-otp-cost-delivery-and-security-ojd</link>
      <guid>https://dev.to/signops/email-otp-vs-sms-otp-cost-delivery-and-security-ojd</guid>
      <description>&lt;p&gt;Both send a short code the user types back. They differ on cost, setup, delivery and what an attacker needs to steal one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SMS OTP&lt;/th&gt;
&lt;th&gt;Email OTP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost each in India&lt;/td&gt;
&lt;td&gt;About Rs 0.13 to Rs 0.25&lt;/td&gt;
&lt;td&gt;A few paise to send; Rs 0.025 on &lt;a href="https://signops.dev/email-otp/" rel="noopener noreferrer"&gt;SignOps&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;DLT entity, header and template registration&lt;/td&gt;
&lt;td&gt;A verified sending domain (SPF, DKIM, DMARC)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proves&lt;/td&gt;
&lt;td&gt;The user has the phone number&lt;/td&gt;
&lt;td&gt;The user has the inbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery&lt;/td&gt;
&lt;td&gt;Usually seconds; can fail on weak signal or DND issues&lt;/td&gt;
&lt;td&gt;Usually seconds; can land in spam if the domain is not set up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SIM swap risk&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phishing risk&lt;/td&gt;
&lt;td&gt;Yes, users read codes out to callers&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works without a phone&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, on any device with email&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When SMS is the right choice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;You need to &lt;strong&gt;prove a phone number&lt;/strong&gt;, for example for a delivery or payments app, or for KYC.&lt;/li&gt;
&lt;li&gt;Your users may not use email much.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even then, you usually need it &lt;strong&gt;once per user&lt;/strong&gt;, at signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  When email is the right choice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Logins for web apps and SaaS, where users already sign up with email.&lt;/li&gt;
&lt;li&gt;You want to avoid DLT registration and template approval.&lt;/li&gt;
&lt;li&gt;Cost matters at volume: 1 lakh logins a month cost about Rs 17,000 by SMS at Rs 0.17 and about Rs 2,500 by email at Rs 0.025. The &lt;a href="https://signops.dev/tools/otp-cost-calculator/" rel="noopener noreferrer"&gt;OTP cost calculator&lt;/a&gt; works it out for your own numbers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Better than both for returning users
&lt;/h2&gt;

&lt;p&gt;Codes of either kind can be phished. For users who already signed in on a phone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://signops.dev/approve/" rel="noopener noreferrer"&gt;Push approval&lt;/a&gt;:&lt;/strong&gt; the phone shows who is signing in and asks for a number match and Face ID. Nothing to read out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://signops.dev/passkeys/" rel="noopener noreferrer"&gt;Passkeys&lt;/a&gt;:&lt;/strong&gt; a fingerprint or screen lock, and nothing to phish at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A sensible flow: SMS or email once at signup, then passkeys or push approval for logins, with email codes as the fallback. See &lt;a href="https://signops.dev/blog/passkeys-vs-otp/" rel="noopener noreferrer"&gt;passkeys vs OTP&lt;/a&gt; and &lt;a href="https://signops.dev/blog/sms-otp-cost-india/" rel="noopener noreferrer"&gt;SMS OTP cost in India&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is email OTP more secure than SMS OTP?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It avoids SIM swap and SMS interception, but anyone with access to the inbox can read it, and both can be phished. Neither is as strong as a passkey or a signed push approval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which is cheaper in India, email or SMS OTP?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Email. An SMS OTP costs about Rs 0.13 to Rs 0.25 from Indian providers and needs DLT registration; an email code costs a few paise to send, and Rs 0.025 on SignOps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need DLT for email OTP?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. DLT applies to commercial SMS in India. Email codes need a verified sending domain instead.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://signops.dev/blog/email-otp-vs-sms-otp/" rel="noopener noreferrer"&gt;signops.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>authentication</category>
      <category>india</category>
    </item>
    <item>
      <title>WebAuthn errors: NotAllowedError, InvalidStateError and more</title>
      <dc:creator>Rushi Patel</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:25:15 +0000</pubDate>
      <link>https://dev.to/signops/webauthn-errors-notallowederror-invalidstateerror-and-more-1cih</link>
      <guid>https://dev.to/signops/webauthn-errors-notallowederror-invalidstateerror-and-more-1cih</guid>
      <description>&lt;p&gt;WebAuthn reports every failure as a &lt;code&gt;DOMException&lt;/code&gt; with a &lt;code&gt;name&lt;/code&gt;. The &lt;code&gt;message&lt;/code&gt; text differs between Chrome, Safari and Firefox, so branch on &lt;code&gt;err.name&lt;/code&gt; and log the message for yourself. This page lists each name the spec throws from &lt;code&gt;navigator.credentials.create()&lt;/code&gt; (registering a passkey) and &lt;code&gt;navigator.credentials.get()&lt;/code&gt; (signing in), what causes it in practice, and what to show the user.&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;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;cred&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// "NotAllowedError", "The operation either timed out or was not allowed..."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Every WebAuthn error, at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;err.name&lt;/th&gt;
&lt;th&gt;create()&lt;/th&gt;
&lt;th&gt;get()&lt;/th&gt;
&lt;th&gt;Usual cause&lt;/th&gt;
&lt;th&gt;What to do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NotAllowedError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Cancelled, timed out, no matching credential, blocked by policy&lt;/td&gt;
&lt;td&gt;Show "Try again" and a fallback sign-in. Do not count it as a failed attempt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;InvalidStateError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;A credential in &lt;code&gt;excludeCredentials&lt;/code&gt; is already on this authenticator&lt;/td&gt;
&lt;td&gt;Say the passkey is already set up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SecurityError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;rp.id&lt;/code&gt; does not match the page's domain, or the page is not HTTPS&lt;/td&gt;
&lt;td&gt;Fix &lt;code&gt;rp.id&lt;/code&gt; or serve the page over HTTPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NotSupportedError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;None of the algorithms in &lt;code&gt;pubKeyCredParams&lt;/code&gt; is supported&lt;/td&gt;
&lt;td&gt;Include &lt;code&gt;-7&lt;/code&gt; (ES256) and &lt;code&gt;-257&lt;/code&gt; (RS256)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ConstraintError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;residentKey: "required"&lt;/code&gt; or &lt;code&gt;userVerification: "required"&lt;/code&gt; and no authenticator can do it&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;"preferred"&lt;/code&gt; unless you really need it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AbortError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Your &lt;code&gt;AbortSignal&lt;/code&gt; was aborted&lt;/td&gt;
&lt;td&gt;Expected when you cancel; ignore it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TypeError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Bad options: &lt;code&gt;user.id&lt;/code&gt; empty or over 64 bytes, a challenge that is not a buffer&lt;/td&gt;
&lt;td&gt;Fix the options your server sends&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  NotAllowedError
&lt;/h2&gt;

&lt;p&gt;This is the one you will see most, and it covers several causes on purpose. The spec makes the browser answer the same way whether the user cancelled or no credential exists, so a page cannot probe which accounts have passkeys.&lt;/p&gt;

&lt;p&gt;Causes, in the order you are likely to hit them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The user cancelled&lt;/strong&gt; the browser or system prompt, or pressed Escape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The timeout ran out.&lt;/strong&gt; &lt;code&gt;timeout&lt;/code&gt; is in milliseconds and is a hint the browser may change. Five minutes (&lt;code&gt;300000&lt;/code&gt;) is common.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No credential matched (sign-in).&lt;/strong&gt; You sent an &lt;code&gt;allowCredentials&lt;/code&gt; list and none of those ids is on any authenticator the browser can reach: the passkey was deleted, was made on another phone that is not synced, or was made for another &lt;code&gt;rpId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No user gesture.&lt;/strong&gt; Safari in particular wants &lt;code&gt;create()&lt;/code&gt; and &lt;code&gt;get()&lt;/code&gt; called straight from a click handler. An &lt;code&gt;await fetch()&lt;/code&gt; before the call can lose the gesture; fetch the options first, then call on the click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cross-origin iframe without permission.&lt;/strong&gt; The iframe needs &lt;code&gt;allow="publickey-credentials-create; publickey-credentials-get"&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What to do: show a short message ("Sign-in was cancelled. Try again or use another way") and keep a fallback visible. Never lock an account over &lt;code&gt;NotAllowedError&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  InvalidStateError
&lt;/h2&gt;

&lt;p&gt;Only on &lt;code&gt;create()&lt;/code&gt;. Your server listed the user's existing credential ids in &lt;code&gt;excludeCredentials&lt;/code&gt;, which is right, and the authenticator recognised one of them. The user already has a passkey for this account on this device or in this password manager.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;InvalidStateError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;showMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You already have a passkey for this account on this device.&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="nx"&gt;err&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;Keep sending &lt;code&gt;excludeCredentials&lt;/code&gt;. Without it the same phone would save two passkeys for one account and the user could not tell them apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  SecurityError
&lt;/h2&gt;

&lt;p&gt;The browser checks that &lt;code&gt;rp.id&lt;/code&gt; (on &lt;code&gt;create()&lt;/code&gt;) or &lt;code&gt;rpId&lt;/code&gt; (on &lt;code&gt;get()&lt;/code&gt;) is the page's own domain or a parent domain of it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;rp.id&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;https://app.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://app.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://app.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;login.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SecurityError&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;example.co&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SecurityError&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://localhost:3000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localhost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://192.168.1.5:3000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;anything&lt;/td&gt;
&lt;td&gt;SecurityError (IP address)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://staging.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fails: not a secure context&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things follow. Pick the parent domain as &lt;code&gt;rp.id&lt;/code&gt; from the start if you have several subdomains, because passkeys made for &lt;code&gt;app.example.com&lt;/code&gt; will never work on &lt;code&gt;www.example.com&lt;/code&gt;. And test on a phone through an HTTPS tunnel or a real domain, not your laptop's IP. Different domains that must share passkeys need Related Origin Requests, a &lt;code&gt;/.well-known/webauthn&lt;/code&gt; file listing them.&lt;/p&gt;

&lt;h2&gt;
  
  
  NotSupportedError and ConstraintError
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;NotSupportedError&lt;/code&gt; means the authenticator supports none of the algorithms you offered. Offer both:&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;pubKeyCredParams&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="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="s2"&gt;public-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// ES256, every passkey provider&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="s2"&gt;public-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;257&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// RS256, Windows Hello&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ConstraintError&lt;/code&gt; means you demanded something no available authenticator can give: a discoverable credential (&lt;code&gt;residentKey: "required"&lt;/code&gt;) or user verification (&lt;code&gt;userVerification: "required"&lt;/code&gt;), and the user only has, say, an old security key. &lt;code&gt;"preferred"&lt;/code&gt; for both works with every passkey and still gives you a discoverable credential with verification on phones and laptops.&lt;/p&gt;

&lt;h2&gt;
  
  
  AbortError
&lt;/h2&gt;

&lt;p&gt;Your own code aborted the request through the &lt;code&gt;signal&lt;/code&gt; you passed. The usual case is passkey autofill: a conditional &lt;code&gt;get()&lt;/code&gt; waits in the background, and when the user clicks a "Sign in with a passkey" button you abort it first, because only one WebAuthn request can run at a time. Catch &lt;code&gt;AbortError&lt;/code&gt; and do nothing. See &lt;a href="https://signops.dev/blog/passkey-autofill/" rel="noopener noreferrer"&gt;passkey autofill&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors from your server, not the browser
&lt;/h2&gt;

&lt;p&gt;Some failures pass the browser and fail at verification. They are not &lt;code&gt;DOMException&lt;/code&gt;s; your server library raises them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Challenge mismatch.&lt;/strong&gt; The challenge in &lt;code&gt;clientDataJSON&lt;/code&gt; is not the one you stored, often because two tabs started a sign-in. Store one challenge per attempt and expire it in a few minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin mismatch.&lt;/strong&gt; &lt;code&gt;clientDataJSON.origin&lt;/code&gt; is &lt;code&gt;https://www.example.com&lt;/code&gt; and you expected &lt;code&gt;https://example.com&lt;/code&gt;. List every origin your sign-in page is served from. In Android apps the origin is &lt;code&gt;android:apk-key-hash:...&lt;/code&gt;, not your web domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;rpIdHash mismatch.&lt;/strong&gt; The credential was made for another &lt;code&gt;rp.id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counter went backwards.&lt;/strong&gt; Synced passkeys usually report a counter of 0. Accept 0; only treat a lower non-zero counter as suspect.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What SignOps does with these
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://signops.dev/passkeys/" rel="noopener noreferrer"&gt;SignOps Passkeys&lt;/a&gt; makes the options on its server: &lt;code&gt;excludeCredentials&lt;/code&gt; from the user's existing passkeys, &lt;code&gt;residentKey&lt;/code&gt; and &lt;code&gt;userVerification&lt;/code&gt; set to &lt;code&gt;"preferred"&lt;/code&gt;, ES256 and RS256 offered, a fresh challenge per attempt, and the origin and &lt;code&gt;rp.id&lt;/code&gt; checked against the domain and sign-in pages you list in the dashboard. Your page calls &lt;code&gt;passkeys.register()&lt;/code&gt; or &lt;code&gt;passkeys.signIn()&lt;/code&gt; from &lt;code&gt;@signops/web&lt;/code&gt; and gets a token your server confirms. Setup: &lt;a href="https://signops.dev/guides/passkeys-web/" rel="noopener noreferrer"&gt;passkeys on your website&lt;/a&gt;, or test what your browser supports with the &lt;a href="https://signops.dev/tools/passkey-check/" rel="noopener noreferrer"&gt;passkey checker&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does "NotAllowedError The operation either timed out or was not allowed" mean?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It is WebAuthn's catch-all. The user closed or cancelled the prompt, the timeout ran out, the page called the API without a user gesture inside a cross-origin iframe, a permissions policy blocked it, or, for a sign-in, none of the credentials in allowCredentials exist on any authenticator the browser can reach. The browser does not tell your page which, on purpose, so a site cannot learn whether a passkey exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does navigator.credentials.create() throw InvalidStateError?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The authenticator already holds a credential listed in excludeCredentials, so the user tried to register a second passkey for the same account on the same device or password manager. Treat it as "already set up" and show that, not as a failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What causes SecurityError "The relying party ID is not a registrable domain suffix of, nor equal to the current domain"?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The rp.id (or rpId) you passed is not the page's domain or a parent of it. A page on app.example.com can use example.com or app.example.com, never another domain. It also happens on an IP address or a non-HTTPS origin other than localhost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does WebAuthn work on localhost?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Browsers treat &lt;a href="http://localhost" rel="noopener noreferrer"&gt;http://localhost&lt;/a&gt; as a secure context, so the API works there with rp.id set to localhost. Any other plain http origin fails, and so does an IP address such as 192.168.1.5.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between NotAllowedError and AbortError?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AbortError comes from your own code, when the AbortSignal you passed is aborted, for example when you cancel a pending passkey autofill request to start a button sign-in. NotAllowedError comes from the user or the browser.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://www.w3.org/TR/webauthn-3/" rel="noopener noreferrer"&gt;W3C: Web Authentication Level 3, create and get request exceptions&lt;/a&gt;, &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create" rel="noopener noreferrer"&gt;MDN: CredentialsContainer.create()&lt;/a&gt;, &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get" rel="noopener noreferrer"&gt;MDN: CredentialsContainer.get()&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://signops.dev/blog/webauthn-errors/" rel="noopener noreferrer"&gt;signops.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webauthn</category>
      <category>passkeys</category>
      <category>javascript</category>
      <category>security</category>
    </item>
    <item>
      <title>Passkeys vs OTP: what changes for your sign-in</title>
      <dc:creator>Rushi Patel</dc:creator>
      <pubDate>Wed, 28 Oct 2026 03:36:23 +0000</pubDate>
      <link>https://dev.to/signops/passkeys-vs-otp-what-changes-for-your-sign-in-2ehc</link>
      <guid>https://dev.to/signops/passkeys-vs-otp-what-changes-for-your-sign-in-2ehc</guid>
      <description>&lt;p&gt;An OTP proves you can read a code sent to you. A passkey proves you hold a key made for this one website, unlocked by your fingerprint or screen lock. That difference is why passkeys are replacing codes for sign-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a passkey works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Registration.&lt;/strong&gt; The phone or laptop makes a key pair for your domain. The private key stays on the device or in the user's password manager. Your server stores the public key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign-in.&lt;/strong&gt; Your server sends a random challenge. The device asks for the fingerprint or screen lock, signs the challenge with the private key, and your server checks the signature with the public key.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The browser only lets a passkey answer the domain it was made for. A look-alike phishing site gets nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passkeys vs OTP
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SMS or email OTP&lt;/th&gt;
&lt;th&gt;Passkey&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What the user does&lt;/td&gt;
&lt;td&gt;Waits for a code, types it&lt;/td&gt;
&lt;td&gt;One touch or glance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can be phished&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SIM swap risk&lt;/td&gt;
&lt;td&gt;Yes for SMS&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per sign-in&lt;/td&gt;
&lt;td&gt;Rs 0.025 to Rs 0.25&lt;/td&gt;
&lt;td&gt;Nothing to send&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works offline&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, on the device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs a new device to be set up&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, once per device or synced account&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where OTP still fits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Proving a new email or phone number&lt;/strong&gt; at signup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery&lt;/strong&gt; when someone loses every device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users on old devices&lt;/strong&gt; without passkey support.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the usual design is passkeys for sign-in, email OTP for signup and recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding passkeys
&lt;/h2&gt;

&lt;p&gt;The browser API is WebAuthn (&lt;code&gt;navigator.credentials.create&lt;/code&gt; and &lt;code&gt;.get&lt;/code&gt;). The work is on your server: making challenges, storing credentials and checking every response correctly. A minimal flow:&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;// Browser, with SignOps: your server makes a registration token for the signed-in user,&lt;/span&gt;
&lt;span class="c1"&gt;// and the browser makes the passkey with it&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;passkeys&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@signops/web&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="nx"&gt;passkeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;registrationToken&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Later: sign in, with the passkey offered in the email field (autofill)&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;signops_token&lt;/span&gt; &lt;span class="p"&gt;}&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;passkeys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;autofill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// your server confirms signops_token before starting a session&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Android, passkeys go through Credential Manager; on iOS through AuthenticationServices. Flutter and React Native packages wrap both. To see what a browser and device support, open the &lt;a href="https://signops.dev/tools/passkey-check/" rel="noopener noreferrer"&gt;passkey support checker&lt;/a&gt; on it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://signops.dev/passkeys/" rel="noopener noreferrer"&gt;SignOps Passkeys&lt;/a&gt; runs the server side on your own domain, with 1,000 sign-ins a month free and 50,000 on Starter, and &lt;a href="https://signops.dev/email-otp/" rel="noopener noreferrer"&gt;email OTP&lt;/a&gt; on the same account for recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Are passkeys better than OTP?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For sign-in, yes. A passkey only answers the website it was made for, so it cannot be phished, and there is no code to send, read or intercept. OTP is still useful to verify a new email or phone, and to recover an account when the passkey is lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which phones support passkeys?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Android 9 and later, iOS 16 and later, and current Chrome, Safari, Edge and Firefox. Synced passkeys are backed up by Google Password Manager, iCloud Keychain or password managers such as 1Password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I add passkeys to Flutter or React Native?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. On Android they go through Credential Manager and on iOS through AuthenticationServices; Flutter and React Native packages wrap both, and your server must check the WebAuthn responses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if a user loses their phone?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Synced passkeys come back on the new phone through the same Google or Apple account. If not, let them in with an email code and ask them to make a new passkey.&lt;/p&gt;




&lt;p&gt;First published on &lt;a href="https://signops.dev/blog/passkeys-vs-otp/" rel="noopener noreferrer"&gt;signops.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://passkeys.dev/" rel="noopener noreferrer"&gt;passkeys.dev&lt;/a&gt;, &lt;a href="https://developer.android.com/identity/sign-in/credential-manager" rel="noopener noreferrer"&gt;Android: Credential Manager&lt;/a&gt;, &lt;a href="https://www.w3.org/TR/webauthn-2/" rel="noopener noreferrer"&gt;W3C: Web Authentication&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>javascript</category>
      <category>webauthn</category>
    </item>
    <item>
      <title>Is Firebase push notification free? FCM pricing, quotas and rate limits</title>
      <dc:creator>Rushi Patel</dc:creator>
      <pubDate>Wed, 28 Oct 2026 03:27:46 +0000</pubDate>
      <link>https://dev.to/signops/is-firebase-push-notification-free-fcm-pricing-quotas-and-rate-limits-2oad</link>
      <guid>https://dev.to/signops/is-firebase-push-notification-free-fcm-pricing-quotas-and-rate-limits-2oad</guid>
      <description>&lt;p&gt;&lt;strong&gt;Yes, Firebase push notifications are free.&lt;/strong&gt; Firebase lists Cloud Messaging (FCM) as "no-cost" on both its free Spark plan and its paid Blaze plan. Google charges nothing per message, and there is no monthly limit on how many you send. Apple's push service (APNs) is free to send through too. So why do OneSignal, Pusher and others charge? Because sending is free, and everything around sending is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is free
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FCM messages to Android, iOS and web&lt;/td&gt;
&lt;td&gt;Free, unlimited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;APNs messages to iPhones&lt;/td&gt;
&lt;td&gt;Free (needs the Apple Developer Program, which any iOS app has)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web push through browsers' push services&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Topics and topic subscriptions on FCM&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  FCM quotas and rate limits (HTTP v1)
&lt;/h2&gt;

&lt;p&gt;Sending is free, but not unlimited in speed. These are Firebase's documented limits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Messages per project&lt;/td&gt;
&lt;td&gt;600,000 a minute, counted per message, not per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Over a quota&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;429&lt;/code&gt; with error code &lt;code&gt;QUOTA_EXCEEDED&lt;/code&gt;, for the project, one device or a topic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Messages to one Android device&lt;/td&gt;
&lt;td&gt;240 a minute and 5,000 an hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collapsible messages to one device&lt;/td&gt;
&lt;td&gt;A burst of 20, then 1 every 3 minutes; extras are delayed, not dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload&lt;/td&gt;
&lt;td&gt;4 KB per message&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Client errors (&lt;code&gt;400&lt;/code&gt; to &lt;code&gt;499&lt;/code&gt;, except &lt;code&gt;429&lt;/code&gt;) count against the quota, so a list full of dead tokens uses it up faster. The minute is not aligned to the clock, so a retry at :00 can still fail. Firebase asks for exponential backoff starting at no less than 1 minute for messages rejected with &lt;code&gt;429&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raising it:&lt;/strong&gt; in Google Cloud Console, open Quotas and system limits for the Firebase Cloud Messaging API and edit &lt;em&gt;Send requests per minute&lt;/em&gt;. Google considers up to 25% more once usage sits at 80% of the quota for at least 5 minutes a day with client errors under 5%. For a launch or a sale, ask Firebase Support for a temporary increase at least 15 days ahead (30 days above 18 million a minute), at most twice a year.&lt;/p&gt;

&lt;p&gt;Other limits worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Topics:&lt;/strong&gt; fan-out to large topics is not instant; plan for minutes, not seconds, on very large sends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No delivery guarantee:&lt;/strong&gt; FCM accepting a message does not mean the phone showed it. See &lt;a href="https://signops.dev/blog/fcm-notification-not-received/" rel="noopener noreferrer"&gt;why FCM notifications are not received&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is not free: the server around it
&lt;/h2&gt;

&lt;p&gt;FCM delivers to a token. Everything else is yours to build and run:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A database of tokens per user&lt;/strong&gt;, updated when a token changes, a user reinstalls or signs in on another phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sending logic:&lt;/strong&gt; one user to many devices, lists of users, topics, everyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduling&lt;/strong&gt; and time zones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup:&lt;/strong&gt; deleting tokens FCM reports as &lt;code&gt;UNREGISTERED&lt;/code&gt;, or every send gets slower and every count wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A screen for non-developers&lt;/strong&gt; who write offers and reminders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery logs&lt;/strong&gt; to answer "did my user get it".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials:&lt;/strong&gt; HTTP v1 needs a service account and short-lived tokens. See &lt;a href="https://signops.dev/blog/fcm-http-v1-migration/" rel="noopener noreferrer"&gt;the FCM HTTP v1 migration&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That work is what paid push services sell.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that costs to buy
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Free&lt;/th&gt;
&lt;th&gt;Paid&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FCM directly&lt;/td&gt;
&lt;td&gt;Unlimited, you build the server&lt;/td&gt;
&lt;td&gt;Your own servers and time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expo push&lt;/td&gt;
&lt;td&gt;Unlimited, Expo apps only&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OneSignal&lt;/td&gt;
&lt;td&gt;Mobile push up to 1,000 users&lt;/td&gt;
&lt;td&gt;$19 a month plus $0.012 a user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pusher Beams&lt;/td&gt;
&lt;td&gt;1,000 devices&lt;/td&gt;
&lt;td&gt;$29 a month for 10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://signops.dev/pricing/" rel="noopener noreferrer"&gt;SignOps&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;1,000 devices&lt;/td&gt;
&lt;td&gt;Rs 499 a month for 5,000, then Rs 0.20 a device&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;If you have a backend and one app, FCM directly is free and fine. When you need users across devices, topics, scheduling, a composer for your team or delivery logs, you either build that server or pay for one. &lt;a href="https://signops.dev/push/" rel="noopener noreferrer"&gt;SignOps Push&lt;/a&gt; is that server, with your own Firebase and Apple keys, free for 1,000 devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Firebase Cloud Messaging free?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Firebase lists Cloud Messaging as no-cost on both the free Spark plan and the pay-as-you-go Blaze plan. There is no charge per message and no monthly cap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the FCM free limit?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no message limit. There is a rate quota of 600,000 messages a minute per Firebase project by default, which Google can raise for projects that need more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the FCM HTTP v1 quota?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;600,000 messages a minute per project, counted in messages, not requests. Past it the API answers 429 QUOTA_EXCEEDED; retry with backoff starting at 1 minute. One Android device takes up to 240 messages a minute and 5,000 an hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I raise the FCM quota?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Google Cloud Console, under Quotas and system limits for the Firebase Cloud Messaging API, edit Send requests per minute. Google considers up to 25% more once you sit at 80% of the quota for 5 minutes a day with under 5% client errors. For a launch, ask Firebase Support at least 15 days ahead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I pay for iOS pushes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Apple charges nothing to send through APNs. You need the Apple Developer Program membership that every iOS app needs anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then why do push services charge?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For the server around FCM. Storing tokens per user, targeting topics, scheduling, retrying, removing dead tokens, a dashboard for non-developers and delivery logs are all work that FCM leaves to you.&lt;/p&gt;




&lt;p&gt;First published on &lt;a href="https://signops.dev/blog/firebase-push-notification-pricing/" rel="noopener noreferrer"&gt;signops.dev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://firebase.google.com/pricing" rel="noopener noreferrer"&gt;Firebase pricing&lt;/a&gt;, &lt;a href="https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas" rel="noopener noreferrer"&gt;Firebase: FCM throttling and quotas&lt;/a&gt;, &lt;a href="https://firebase.google.com/docs/reference/fcm/rest/v1/ErrorCode" rel="noopener noreferrer"&gt;FCM error codes&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>android</category>
      <category>ios</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Migrating from the FCM legacy API to HTTP v1</title>
      <dc:creator>Rushi Patel</dc:creator>
      <pubDate>Wed, 21 Oct 2026 18:19:27 +0000</pubDate>
      <link>https://dev.to/signops/migrating-from-the-fcm-legacy-api-to-http-v1-3jhi</link>
      <guid>https://dev.to/signops/migrating-from-the-fcm-legacy-api-to-http-v1-3jhi</guid>
      <description>&lt;p&gt;If your server still posts to &lt;code&gt;https://fcm.googleapis.com/fcm/send&lt;/code&gt; with a server key, it has stopped delivering. Firebase deprecated the legacy HTTP and XMPP APIs on &lt;strong&gt;20 June 2023&lt;/strong&gt; and began shutting them down on &lt;strong&gt;22 July 2024&lt;/strong&gt;. The replacement is the &lt;strong&gt;HTTP v1 API&lt;/strong&gt;. Your app does not change. Your server does, in three places: how it authenticates, where it sends, and what the payload looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes between legacy and v1
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Legacy API&lt;/th&gt;
&lt;th&gt;HTTP v1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /fcm/send&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /v1/projects/PROJECT_ID/messages:send&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Authorization: key=SERVER_KEY&lt;/code&gt;, never expires&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Authorization: Bearer ACCESS_TOKEN&lt;/code&gt;, from a service account, lasts about an hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many devices&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;registration_ids&lt;/code&gt; with up to 1,000 tokens&lt;/td&gt;
&lt;td&gt;One target per request: a token, a topic or a condition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform options&lt;/td&gt;
&lt;td&gt;Mixed into one object&lt;/td&gt;
&lt;td&gt;Separate &lt;code&gt;android&lt;/code&gt;, &lt;code&gt;apns&lt;/code&gt; and &lt;code&gt;webpush&lt;/code&gt; blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data values&lt;/td&gt;
&lt;td&gt;Any JSON&lt;/td&gt;
&lt;td&gt;Strings only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 1: get a service account key
&lt;/h2&gt;

&lt;p&gt;In the Firebase console open &lt;strong&gt;Project settings, Service accounts&lt;/strong&gt; and generate a new private key. Keep the JSON file out of your repo and your app. It is a password to every device of your project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: turn the key into an access token
&lt;/h2&gt;

&lt;p&gt;The token is an OAuth 2.0 bearer token with the scope &lt;code&gt;https://www.googleapis.com/auth/firebase.messaging&lt;/code&gt;. Libraries fetch and refresh it for you.&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;// Node: npm install google-auth-library&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GoogleAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;google-auth-library&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;auth&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;GoogleAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;keyFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service-account.json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scopes&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;https://www.googleapis.com/auth/firebase.messaging&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;token&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAccessToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: send with the v1 payload
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ACCESS_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "message": {
      "token": "DEVICE_TOKEN",
      "notification": { "title": "Order shipped", "body": "Arriving today" },
      "data": { "order_id": "88213" },
      "android": { "priority": "high" },
      "apns": { "headers": { "apns-priority": "10" } }
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things trip people up here. Every value in &lt;code&gt;data&lt;/code&gt; must be a string, so send &lt;code&gt;"88213"&lt;/code&gt;, not &lt;code&gt;88213&lt;/code&gt;. And &lt;code&gt;priority&lt;/code&gt; moved into the &lt;code&gt;android&lt;/code&gt; block.&lt;/p&gt;

&lt;h3&gt;
  
  
  The same in PHP
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// composer require google/auth guzzlehttp/guzzle&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Google\Auth\Credentials\ServiceAccountCredentials&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nv"&gt;$creds&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;ServiceAccountCredentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'https://www.googleapis.com/auth/firebase.messaging'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file_get_contents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'service-account.json'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$creds&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchAuthToken&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="s1"&gt;'access_token'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nv"&gt;$client&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;GuzzleHttp\Client&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"https://fcm.googleapis.com/v1/projects/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$projectId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/messages:send"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'headers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'Authorization'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="s1"&gt;'json'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'token'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$deviceToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'notification'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Order shipped'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'body'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Arriving today'&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;h2&gt;
  
  
  Step 4: replace registration_ids
&lt;/h2&gt;

&lt;p&gt;HTTP v1 has no list of tokens. You have two choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One request per token.&lt;/strong&gt; The Firebase Admin SDK does this in parallel with &lt;code&gt;sendEach&lt;/code&gt; and &lt;code&gt;sendEachForMulticast&lt;/code&gt;. The older &lt;code&gt;sendAll&lt;/code&gt; and &lt;code&gt;sendMulticast&lt;/code&gt; used a batch endpoint that was retired with the legacy API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Topics.&lt;/strong&gt; Subscribe devices to a topic once and send one message to &lt;code&gt;"topic": "offers"&lt;/code&gt;. FCM fans it out.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// npm install firebase-admin&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;initializeApp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cert&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;firebase-admin/app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getMessaging&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;firebase-admin/messaging&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;initializeApp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service-account.json&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;res&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;getMessaging&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;sendEachForMulticast&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;deviceTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// up to 500 per call&lt;/span&gt;
  &lt;span class="na"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Order shipped&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Arriving today&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;successCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sent,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;failureCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: handle the new errors
&lt;/h2&gt;

&lt;p&gt;v1 errors are clearer than legacy ones. The two you will see most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UNREGISTERED&lt;/code&gt;&lt;/strong&gt; (404): the token is dead, usually because the app was uninstalled. Delete it from your database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;INVALID_ARGUMENT&lt;/code&gt;&lt;/strong&gt; (400): a malformed token or payload. A number inside &lt;code&gt;data&lt;/code&gt; is the usual cause.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the cleanup automatic. A list of dead tokens slows every send and inflates every count.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not run this
&lt;/h2&gt;

&lt;p&gt;All of the above is what &lt;a href="https://signops.dev/push/" rel="noopener noreferrer"&gt;SignOps Push&lt;/a&gt; does: it holds your service account and APNs key, sends through HTTP v1 to every device a user has, removes dead tokens and tells your server by webhook. You call one endpoint with your own user id. It is free for 1,000 devices.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://firebase.google.com/docs/cloud-messaging/migrate-v1" rel="noopener noreferrer"&gt;Firebase: migrate from legacy FCM APIs to HTTP v1&lt;/a&gt;, &lt;a href="https://firebase.google.com/docs/cloud-messaging/throttling-and-quotas" rel="noopener noreferrer"&gt;Firebase: FCM throttling and quotas&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>android</category>
      <category>firebase</category>
      <category>node</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
