<?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: Ritusmoi Kaushik</title>
    <description>The latest articles on DEV Community by Ritusmoi Kaushik (@ritusmoikaushik).</description>
    <link>https://dev.to/ritusmoikaushik</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%2F3890265%2Feccb3254-aef7-4c24-ba6f-6ee8563a0847.jpg</url>
      <title>DEV Community: Ritusmoi Kaushik</title>
      <link>https://dev.to/ritusmoikaushik</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ritusmoikaushik"/>
    <language>en</language>
    <item>
      <title>The Night the Clocks Go Back: The Daylight-Saving Bug in Time Card Maths</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Mon, 26 Oct 2026 07:47:27 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/the-night-the-clocks-go-back-the-daylight-saving-bug-in-time-card-maths-hon</link>
      <guid>https://dev.to/ritusmoikaushik/the-night-the-clocks-go-back-the-daylight-saving-bug-in-time-card-maths-hon</guid>
      <description>&lt;p&gt;I run a small free website, a time card calculator. You type in when you clocked in and out each day, it adds up the week, splits out the overtime, and gives you a spreadsheet to hand over. Quite a few of the people it's built for work nights: nurses, security guards, the late shift at a warehouse.&lt;/p&gt;

&lt;p&gt;On Sunday 1 November, at 2am, clocks in most of the US go back an hour. If you work 10pm to 6am that night, you'll be on shift for nine hours. Your punches will say 10:00 PM and 6:00 AM, which looks exactly like every other night of the year.&lt;/p&gt;

&lt;p&gt;My calculator said eight.&lt;/p&gt;

&lt;p&gt;This is the bug, the fix (five lines, and no table of dates anywhere), and the one case it still can't get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug is a subtraction
&lt;/h2&gt;

&lt;p&gt;Most time card maths looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// minutes after midnight, as the clock read them&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shiftMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inMin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outMin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;outMin&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;inMin&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;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// the shift ran past midnight&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;shiftMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 480&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's nothing wrong with it on 363 nights a year. On the other two, it subtracts what the clock &lt;em&gt;said&lt;/em&gt;, and the clock lied. When US clocks go back, 1:59am is followed by 1:00am again, so the hour from 1 to 2 happens twice, and anyone on shift through it works both of them. In March it goes the other way. 1:59 is followed by 3:00, and the same shift is seven hours.&lt;/p&gt;

&lt;p&gt;An hourly worker is paid for the hours they worked. That night they worked nine. If they were already near forty for the week, the missing hour is an overtime hour too, so it's the expensive kind.&lt;/p&gt;

&lt;p&gt;I recently went through about twenty free time card calculators. None of them adjusted for this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tempting fix is a table
&lt;/h2&gt;

&lt;p&gt;My first thought was a list of changeover dates. Second Sunday in March, first Sunday in November, done.&lt;/p&gt;

&lt;p&gt;It isn't done, though. That's only the US rule, and only since 2007, when the Energy Policy Act of 2005 moved the dates. Most of Arizona doesn't change its clocks at all. The UK went back on 25 October, a week before the US. Australia goes back in April, and only some of its states bother. A table means I own every one of those rules and every future change to them. I'm one person with a calculator site. I'd get one wrong eventually, and I'd find out from someone's payslip.&lt;/p&gt;

&lt;p&gt;The browser already knows all of this. It has to, because it shows you the right local time all year round. So the real question was how to get the browser to tell me how long a shift actually lasted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let Date do the arithmetic
&lt;/h2&gt;

&lt;p&gt;A JavaScript &lt;code&gt;Date&lt;/code&gt; is an instant: milliseconds since 1970, in UTC. When you build one from local parts (year, month, day, hours, minutes) the browser turns your wall-clock reading into that instant using its own time zone rules. Subtract two instants and you get elapsed time. Not clock time. Elapsed time.&lt;/p&gt;

&lt;p&gt;The second half of the trick is that the &lt;code&gt;Date&lt;/code&gt; constructor doesn't mind minutes past 59. It rolls them over. &lt;code&gt;new Date(2026, 9, 31, 0, 1800)&lt;/code&gt; is midnight on 31 October plus 1,800 minutes, which is 6am on 1 November. So I can describe any shift as "minutes after midnight on the day it started", including one that runs into the next morning, and let the constructor sort out the dates.&lt;/p&gt;

&lt;p&gt;That's the whole fix, simplified a little:&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;// from and to: minutes after midnight on dateISO, as the clock read them.&lt;/span&gt;
&lt;span class="c1"&gt;// Returns the minutes a clock change added (+60) or took away (-60).&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;clockChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dateISO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dateISO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;real&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mo&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;)&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mo&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;real&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;clockChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-31&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 60, in New York&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The real one also returns 0 when the card has no date on it. Without a date there's nothing to know.)&lt;/p&gt;

&lt;p&gt;The card adds that to the day's total and writes a note on the line: "Clocks went back during this shift — the extra hour is counted." Honestly, the note matters as much as the number. A payroll clerk who sees nine hours next to punches of 10pm and 6am will assume the calculator is broken unless it says why.&lt;/p&gt;

&lt;p&gt;Here's the same 10pm to 6am shift, started on the Saturday night before each change:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time zone&lt;/th&gt;
&lt;th&gt;Shift starts&lt;/th&gt;
&lt;th&gt;Clock change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;America/New_York&lt;/td&gt;
&lt;td&gt;Sat 31 Oct 2026&lt;/td&gt;
&lt;td&gt;+60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;America/Phoenix&lt;/td&gt;
&lt;td&gt;Sat 31 Oct 2026&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Europe/London&lt;/td&gt;
&lt;td&gt;Sat 24 Oct 2026&lt;/td&gt;
&lt;td&gt;+60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Australia/Sydney&lt;/td&gt;
&lt;td&gt;Sat 4 Apr 2026&lt;/td&gt;
&lt;td&gt;+60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;America/New_York&lt;/td&gt;
&lt;td&gt;Sat 7 Mar 2026&lt;/td&gt;
&lt;td&gt;−60&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Phoenix gets zero because Arizona doesn't change its clocks. I didn't have to know that. The browser did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shifts with more than one punch
&lt;/h2&gt;

&lt;p&gt;The calculator lets you clock in and out as many times as you like in a day. A night might be 8pm to 11pm, 11:30pm to 1am, then 1:30am to 5am after a break. Only the last pair crosses 2am, and only that pair should get the extra hour.&lt;/p&gt;

&lt;p&gt;So every pair goes on one running timeline for the night, each read forward from where the one before it ended:&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="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;inMin&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inMin&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;clocks&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;clockChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;day&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 1:30am clock-in comes after a pair that ended at 1am, so it lands at minute 1530 of the timeline rather than minute 90, and &lt;code&gt;new Date(..., 0, 1530)&lt;/code&gt; points at the right morning. Get this wrong and the hour goes to the wrong pair, or to nobody. It's the sort of thing you only notice when you write the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The case it can't get right
&lt;/h2&gt;

&lt;p&gt;There's one I can't fix, and I'd rather say so than pretend.&lt;/p&gt;

&lt;p&gt;On the night clocks go back, 1:30am happens twice. If someone clocks in at 1:30, which one was it? A paper time card doesn't say. Neither does a time typed into a box. JavaScript has to pick, and MDN describes the rule (it's the same as Temporal's &lt;code&gt;"compatible"&lt;/code&gt; setting): if a local time happens twice, the earlier one is chosen, and if it never happened, like 2:30 on the night clocks go forward, it moves forward by the size of the gap, so 2:30 becomes 3:30.&lt;/p&gt;

&lt;p&gt;So a shift typed as 1am to 3am on 1 November comes out as three hours. That's right if they clocked in before the change and an hour too much if they clocked in after it.&lt;/p&gt;

&lt;p&gt;Real time clocks avoid this by recording the instant, or the UTC offset, at the moment of the punch. If you're building anything that records punches, do that. Store the instant, and keep the wall-clock text for display. Rebuilding instants from wall-clock times, which is what I'm doing, is the fallback for when wall-clock times are all you've got. For one hour a year, it's a guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's the device's time zone, not the workplace's
&lt;/h2&gt;

&lt;p&gt;The other limit is quieter. The browser uses its own time zone. If a payroll clerk in Phoenix checks a card for someone who works in Denver, it applies Arizona's rules and adds nothing.&lt;/p&gt;

&lt;p&gt;I thought about a time zone dropdown and left it out, for now anyway. The site is built for people checking their own hours, on their own phone, where they work. A dropdown would be one more thing for everyone else to get wrong. Instead the card only adjusts when it has a date, and it always says on the line when it has. If the note is there and shouldn't be, a person can see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it without moving to Arizona
&lt;/h2&gt;

&lt;p&gt;The tests run in plain Node, with no dependencies. Node's &lt;code&gt;Date&lt;/code&gt; follows the &lt;code&gt;TZ&lt;/code&gt; environment variable, and since Node 13 on Linux and macOS (16.2 on Windows) you can change &lt;code&gt;process.env.TZ&lt;/code&gt; partway through a script and the next &lt;code&gt;Date&lt;/code&gt; picks it up. So one test file visits four time zones in turn:&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TZ&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;America/New_York&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clocks go back on 1 Nov 2026: 10pm to 6am is 9 hours&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dayMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;nightShift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-31&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;540&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clocks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TZ&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;America/Phoenix&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1 Nov night shift stays 8 hours&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dayMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;nightShift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-31&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;480&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two of the tests I'd suggest to anyone doing this. The first is a shift on the change date that finishes before 2am, to prove it gets nothing. The second is the weekly total: four ordinary nights plus the change night is 41 hours, and the 41st is overtime. That's the one that costs real money if it's missing.&lt;/p&gt;

&lt;p&gt;One thing that caught me out while writing this up. On Windows, running &lt;code&gt;TZ=America/New_York node script.js&lt;/code&gt; from Git Bash didn't take for me, and the dates came out in my own zone, while setting &lt;code&gt;process.env.TZ&lt;/code&gt; inside the script worked fine. If your daylight-saving tests pass suspiciously easily, print &lt;code&gt;new Date().toString()&lt;/code&gt; once and check which zone you're really in.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the spreadsheet
&lt;/h2&gt;

&lt;p&gt;The main thing the calculator produces is an Excel file, and that's where a payroll person actually checks the hours. A number that's quietly an hour bigger than the punches will get "corrected" by somebody. So the sheet says it too. The main card writes "(clocks went back 1h)" next to the punches. The newer 24-hour card has a Clock change column that feeds each line's hours formula, so if someone edits a time in Excel, the hour is still counted.&lt;/p&gt;

&lt;p&gt;If you've got a time card, a rota tool or anything that bills by the hour and subtracts clock times, try it with a 10pm to 6am shift starting on 31 October. It takes a minute, and there are still a few days before it matters.&lt;/p&gt;

&lt;p&gt;The calculator is at &lt;a href="https://hourstally.com" rel="noopener noreferrer"&gt;hourstally.com&lt;/a&gt;. It's free and runs entirely in the browser, and &lt;code&gt;clockChange&lt;/code&gt; is in &lt;code&gt;assets/app.js&lt;/code&gt; if you'd like to read the real thing. If you've hit a daylight-saving bug of your own, I'd love to hear it. They're always a bit funny once they're fixed.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A Real .xlsx With Working Formulas in Plain JavaScript, No Library</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Tue, 13 Oct 2026 06:02:02 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/a-real-xlsx-with-working-formulas-in-plain-javascript-no-library-50bc</link>
      <guid>https://dev.to/ritusmoikaushik/a-real-xlsx-with-working-formulas-in-plain-javascript-no-library-50bc</guid>
      <description>&lt;p&gt;I run a small free website, a time card calculator. You type in when you clocked in and out each day, it adds up the week, splits regular hours from overtime, and gives you something to hand over. It's built for hourly workers checking their pay, and for small business owners doing payroll for a handful of staff on a Friday afternoon.&lt;/p&gt;

&lt;p&gt;That last part, "something to hand over", is the bit that matters most. A total on a web page is nice. What the manager or the payroll person actually wants is a file they can open in Excel, check, and keep.&lt;/p&gt;

&lt;p&gt;For its first few days the site offered a CSV download. CSV is fine for data. It's bad at being a timesheet, because Excel has to guess what every cell is. A total like 41:30 might stay text, or turn into a time, and if anyone then formats that column the usual way it reads 17:30. Nobody wants to explain to their manager why their timesheet says seventeen and a half hours.&lt;/p&gt;

&lt;p&gt;So I wanted a proper .xlsx. Real dates, real time cells, and a total that is an actual &lt;code&gt;=SUM()&lt;/code&gt; formula, so if you fix a typo in Tuesday's hours the week total moves with it.&lt;/p&gt;

&lt;p&gt;The obvious answer is a library. I didn't take it. The whole site ships about 80 KB of JavaScript across five files, and I wasn't keen on a dependency several times that size for one button. What I ended up with is 177 lines and 9 KB, unminified. This is how it works, and the one bug that got through anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  An .xlsx is a zip you can write by hand
&lt;/h2&gt;

&lt;p&gt;Rename any .xlsx to .zip and open it. Strip out everything Excel doesn't strictly need and you're left with six small files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Content_Types].xml          what each part is
_rels/.rels                  "the workbook is over there"
xl/workbook.xml              the list of sheets
xl/_rels/workbook.xml.rels   "sheet 1 and the styles are over there"
xl/styles.xml                number formats, bold
xl/worksheets/sheet1.xml     the actual cells
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five of those are boilerplate you write once and never touch. The interesting one is the sheet.&lt;/p&gt;

&lt;p&gt;The zip part sounds like the scary bit. It isn't, because Excel happily opens a zip whose entries are &lt;em&gt;stored&lt;/em&gt; (not compressed). No deflate, no compression library. You need a CRC-32 per file and two kinds of header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CRC_TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&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;Uint32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&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="mh"&gt;0xEDB88320&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;crc32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mh"&gt;0xFFFFFFFF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CRC_TABLE&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="mh"&gt;0xFFFFFFFF&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, for each file: a local header (signature &lt;code&gt;0x04034b50&lt;/code&gt;, the CRC, the size twice because compressed and uncompressed are the same, the name), the bytes, and later a central directory entry pointing back at it. One end-of-directory record closes the lot. All little-endian, which is where most of the fiddly bits live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u16&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&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;u32&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFF&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One small trick I'd recommend. Every entry gets the same fixed timestamp, 1 January 1980, which is the zero point of the old DOS date format. That means the same time card always produces a byte-identical file. You'd be surprised how much easier that makes testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cells: strings, numbers, and formulas that already know their answer
&lt;/h2&gt;

&lt;p&gt;A row in the sheet looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;row&lt;/span&gt; &lt;span class="na"&gt;r=&lt;/span&gt;&lt;span class="s"&gt;"9"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;c&lt;/span&gt; &lt;span class="na"&gt;r=&lt;/span&gt;&lt;span class="s"&gt;"A9"&lt;/span&gt; &lt;span class="na"&gt;s=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt; &lt;span class="na"&gt;t=&lt;/span&gt;&lt;span class="s"&gt;"inlineStr"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;is&amp;gt;&amp;lt;t&amp;gt;&lt;/span&gt;Total hours&lt;span class="nt"&gt;&amp;lt;/t&amp;gt;&amp;lt;/is&amp;gt;&amp;lt;/c&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;c&lt;/span&gt; &lt;span class="na"&gt;r=&lt;/span&gt;&lt;span class="s"&gt;"E9"&lt;/span&gt; &lt;span class="na"&gt;s=&lt;/span&gt;&lt;span class="s"&gt;"5"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;f&amp;gt;&lt;/span&gt;SUM(E2:E8)&lt;span class="nt"&gt;&amp;lt;/f&amp;gt;&amp;lt;v&amp;gt;&lt;/span&gt;1.7291666666666667&lt;span class="nt"&gt;&amp;lt;/v&amp;gt;&amp;lt;/c&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/row&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noticing.&lt;/p&gt;

&lt;p&gt;Strings are written inline (&lt;code&gt;t="inlineStr"&lt;/code&gt;). The "proper" way is a shared strings table, where every distinct string is stored once and cells point at it by index. For a sheet with forty cells, honestly, that's ceremony. Inline strings are part of the spec and Excel reads them fine.&lt;/p&gt;

&lt;p&gt;The formula cell carries both the formula &lt;em&gt;and&lt;/em&gt; its result. The &lt;code&gt;&amp;lt;f&amp;gt;&lt;/code&gt; is what Excel keeps and recalculates. The &lt;code&gt;&amp;lt;v&amp;gt;&lt;/code&gt; is the cached value, and I compute it myself in JavaScript before writing the file. Excel shows it straight away, and so does anything that previews the file without calculating. Leave &lt;code&gt;&amp;lt;v&amp;gt;&lt;/code&gt; out and those previews can show an empty total, which is exactly the thing people look at first.&lt;/p&gt;

&lt;p&gt;The cell objects I build are tiny:&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="nl"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;totalMinutes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1440&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;STYLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;boldHm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUM(E2:E8)&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;p&gt;&lt;code&gt;v&lt;/code&gt; is the cached value, &lt;code&gt;s&lt;/code&gt; a style index, &lt;code&gt;f&lt;/code&gt; the formula without its &lt;code&gt;=&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Time is a fraction of a day, and h:mm lies
&lt;/h2&gt;

&lt;p&gt;Excel stores a duration as a fraction of one day. Eight hours is &lt;code&gt;0.3333…&lt;/code&gt;, which is why the value above is &lt;code&gt;minutes / 1440&lt;/code&gt;. So far so normal.&lt;/p&gt;

&lt;p&gt;The catch is the number format. The everyday &lt;code&gt;h:mm&lt;/code&gt; format &lt;em&gt;wraps at 24 hours&lt;/em&gt;, like a clock. Put 41 hours 30 minutes in a cell formatted &lt;code&gt;h:mm&lt;/code&gt; and Excel shows &lt;code&gt;17:30&lt;/code&gt;. Nothing is wrong with the number. The display is just a clock face, and a clock doesn't know about Thursday.&lt;/p&gt;

&lt;p&gt;The fix is a custom format with square brackets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;numFmt&lt;/span&gt; &lt;span class="na"&gt;numFmtId=&lt;/span&gt;&lt;span class="s"&gt;"164"&lt;/span&gt; &lt;span class="na"&gt;formatCode=&lt;/span&gt;&lt;span class="s"&gt;"[h]:mm"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[h]&lt;/code&gt; means "total hours, don't wrap". It's one character pair, and it's the difference between a time card and a broken one. Custom formats start at ID 164 because 0 to 163 are reserved for Excel's built-ins. I also keep a plain &lt;code&gt;0.00&lt;/code&gt; decimal column next to it, because payroll people want 41.50, not 41:30.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dates start in 1899, because of a bug from the 80s
&lt;/h2&gt;

&lt;p&gt;An Excel date is a whole number: days since an epoch. You'd expect the epoch to be 1 January 1900. In practice the maths only works if you count from &lt;strong&gt;30 December 1899&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dateSerial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;iso&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;25569&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;25569 is the number of days from 1899-12-30 to the Unix epoch. The odd start date is there because Lotus 1-2-3 treated 1900 as a leap year (it wasn't), Excel copied that for compatibility, and the cheapest way to make modern dates come out right is to shift the origin back a day. Dates from March 1900 onwards are correct. Dates in January and February 1900 are off by one, and I'm fairly sure no one is filling in a time card for those.&lt;/p&gt;

&lt;p&gt;I have two tests pinning it: 2026-09-07 is 46272, and 1900-03-01 is 61. If either ever changes, something upstream broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ##### bug
&lt;/h2&gt;

&lt;p&gt;Here's the part I didn't see coming.&lt;/p&gt;

&lt;p&gt;Everything above passed my tests. The zip was valid, every CRC checked out, every formula was where it should be. Then the first real test on a real PC, in real Excel, opened the two-week sheet and the pay period dates read &lt;code&gt;#####&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That isn't an error. It's Excel saying "this number doesn't fit in this column". I'd made column B six characters wide, because on most rows it only holds the weekday: "Mon", "Tue". On the summary rows the same column holds a date like "28 Sep 2026", and six characters is nowhere near enough. All three of my sheets had the same mistake. None of my tests knew how wide a date is, so none of them caught it.&lt;/p&gt;

&lt;p&gt;The fix was boring: column B became 12 wide. The more useful change was a new test that walks every cell, and if it's a date, checks that its column is at least 11 characters wide. Whatever Excel will refuse to display, I'd rather fail on before anyone sees it.&lt;/p&gt;

&lt;p&gt;The lesson I took from it, for what it's worth: a file format test tells you the file is valid, not that a person can read it. Open the thing in the real program, on a real machine, before calling it done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a zip without a zip library
&lt;/h2&gt;

&lt;p&gt;The tests run in plain Node with no dependencies, so I couldn't just &lt;code&gt;require&lt;/code&gt; an unzip package either. Instead the test file has a small zip &lt;em&gt;reader&lt;/em&gt;, about twenty lines: find the end-of-directory record, walk the central directory, jump to each local header, and check every entry's CRC against the bytes.&lt;/p&gt;

&lt;p&gt;And CRC-32 has a published check value, which is handy: the CRC of the ASCII string &lt;code&gt;123456789&lt;/code&gt; must be &lt;code&gt;0xCBF43926&lt;/code&gt;. If your table is wrong, that test tells you in a millisecond.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I wouldn't do this
&lt;/h2&gt;

&lt;p&gt;If you need charts, several sheets with thousands of rows, or reading .xlsx files back in, use a library. Shared strings and compression start to matter at that size, and you'd be rebuilding something well-tested for no good reason.&lt;/p&gt;

&lt;p&gt;But for "give the user a tidy spreadsheet of what they just typed", a hand-written writer is small and fast, and you understand every byte of it. Mine downloads with the week's punches, regular and overtime hours, and a total that is a live &lt;code&gt;=SUM()&lt;/code&gt;. It's the Excel button on &lt;a href="https://hourstally.com" rel="noopener noreferrer"&gt;hourstally.com&lt;/a&gt;, a free time card calculator that runs entirely in the browser, and the code is just &lt;code&gt;assets/xlsx.js&lt;/code&gt; if you want to read the whole thing.&lt;/p&gt;

&lt;p&gt;If you've written .xlsx by hand and hit a different trap than these, I'd genuinely like to hear about it.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>excel</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>11 weeks of Search Console data from a new site, including the part that failed</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Thu, 23 Jul 2026 09:42:44 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/11-weeks-of-search-console-data-from-a-new-site-including-the-part-that-failed-10ig</link>
      <guid>https://dev.to/ritusmoikaushik/11-weeks-of-search-console-data-from-a-new-site-including-the-part-that-failed-10ig</guid>
      <description>&lt;p&gt;Most write-ups about ranking a new site come from people who already won. They tell you what worked, in order, as if it were a plan rather than a reconstruction. I want to publish the other version, with the actual numbers, while the outcome is still genuinely uncertain.&lt;/p&gt;

&lt;p&gt;I launched a browser-based file tools site on 8 May 2026. Everything runs client-side — no uploads, no server, WebAssembly and canvas in the tab. Eleven weeks later I have 42 pages, 1,445 impressions in the last 28 days, and &lt;strong&gt;2 clicks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That number isn't the interesting part. The interesting part is what the data says about &lt;em&gt;why&lt;/em&gt;, and how badly wrong I was for the first two months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: two months of building the wrong thing
&lt;/h2&gt;

&lt;p&gt;The launch plan was thirteen tools: image compress, resize, crop, format convert, PDF merge, split, compress, PDF to JPG, and so on. The reasoning was that these are high-volume evergreen queries, the tech is straightforward, and TinyPNG and Smallpdf have proven the model.&lt;/p&gt;

&lt;p&gt;Here's what Search Console said after two months, 28 days to 5 July:&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;Impressions&lt;/th&gt;
&lt;th&gt;Avg position&lt;/th&gt;
&lt;th&gt;Clicks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;/tools/resize-image/&lt;/td&gt;
&lt;td&gt;174&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86.7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/tools/crop-image/&lt;/td&gt;
&lt;td&gt;107&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;80.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"resize image online" (query)&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;90.9&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"crop photo online" (query)&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;78.6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;every other generic tool page&lt;/td&gt;
&lt;td&gt;≤4 each&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sitewide: 5 clicks, ~550 impressions.&lt;/p&gt;

&lt;p&gt;Position 80 to 95 is not "ranking slowly." Nobody has ever seen page nine of a search result. Those are the positions Google assigns when it has indexed your page, understood it, and concluded there are eighty better answers.&lt;/p&gt;

&lt;p&gt;The pages weren't bad. They're fast, the schema is right, the tools actually work and don't upload your files. That's precisely what made the data useful: quality wasn't the variable. I had picked a fight with TinyPNG, iLoveIMG, Canva, Adobe and Google's own Squoosh, from a domain with no authority and no links. A better-executed version of the same page would have ranked 84 instead of 87.&lt;/p&gt;

&lt;p&gt;But two rows in that same export looked completely different:&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;Impressions&lt;/th&gt;
&lt;th&gt;Avg position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;/blog/heic-files-windows/&lt;/td&gt;
&lt;td&gt;86&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.9&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/blog/why-heic-not-supported/&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same domain. Same age. Same zero backlinks. Position 9.&lt;/p&gt;

&lt;p&gt;The only difference was the topic. HEIC is the format Apple made default on every iPhone in 2017 that Windows still can't open properly. It's a real recurring problem, and the big converter sites treat it as one row in a format list rather than something to own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: the pivot
&lt;/h2&gt;

&lt;p&gt;On 10 July I stopped building generic converters and repositioned the entire site around what I started calling orphan formats: files one ecosystem creates that nothing else can open. HEIC from iPhones. AVIF from web pages. JFIF, which is Windows' own bizarre habit of saving JPEGs under a different extension. Apple's &lt;code&gt;.numbers&lt;/code&gt; spreadsheets, which Excel refuses outright.&lt;/p&gt;

&lt;p&gt;Three days of work, shipped 13 July: three new converter tools, four explainer articles, and a homepage that stopped saying "free image and PDF tools" and started saying "open the files your device can't."&lt;/p&gt;

&lt;p&gt;The thirteen generic tools are still live and still indexed. I just stopped linking to them from anywhere on the site. Deleting them would throw away whatever long-tail Google already gives them, but a site that visibly &lt;em&gt;is&lt;/em&gt; one thing reads as more authoritative than one padded with eleven me-too converters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: what the data says now
&lt;/h2&gt;

&lt;p&gt;28 days to 18 July:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1,445 impressions&lt;/strong&gt;, up from ~550&lt;/li&gt;
&lt;li&gt;Impressions per day went from 11–19 to &lt;strong&gt;60–260&lt;/strong&gt; after the 13 July push&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;21 queries ranking positions 1–10&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those 21 is an orphan-format or informational long-tail:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;avif to jpg&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;numbers save as xlsx&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;is .heic safe&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;what is jfif file&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jfif vs jpg&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pages published on 13 July were on page one within days. No backlinks pointed at them.&lt;/p&gt;

&lt;p&gt;Meanwhile the head terms remain exactly as locked as they were:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Impressions&lt;/th&gt;
&lt;th&gt;Position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;heic&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;heic file&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.heic&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;86&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That contrast is the whole lesson, and it's a sharper version of what I got wrong in May. It isn't that a new site can't rank. It's that a new site can rank &lt;em&gt;immediately&lt;/em&gt; for questions nobody has bothered to answer well, and &lt;em&gt;never&lt;/em&gt; for the terms everyone is fighting over. Same site, same week, positions 3 and 90.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: so why only 2 clicks?
&lt;/h2&gt;

&lt;p&gt;Because the queries I can win are tiny.&lt;/p&gt;

&lt;p&gt;Each of those page-one queries carries &lt;strong&gt;1 to 6 impressions over 28 days&lt;/strong&gt;. At position 8 with 3 impressions, expected clicks is about 0.3. Two clicks across the whole site is statistically unremarkable, not a failure signal.&lt;/p&gt;

&lt;p&gt;I want to be careful here, because this is exactly the kind of correct math that lets you feel fine for six more months while nothing happens. So let me state the uncomfortable version too.&lt;/p&gt;

&lt;p&gt;Run the arithmetic forward. If each new guide wins 20 to 30 of these long-tails, and each carries a handful of impressions, a guide is worth maybe 5 to 10 clicks a month at maturity. Publish two a week for six months and you have roughly 50 guides and maybe 400 sessions a month. At display-ad rates that's &lt;strong&gt;two to five dollars a month&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The strategy is working and the strategy's ceiling is also very low. Both are true. Coverage compounds, but it compounds from a small base, and I don't yet know whether the curve bends before I run out of patience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 5: four things in the data I hadn't expected
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Blogs massively outrank tool pages.&lt;/strong&gt; My articles sit at positions 15–21. The tool pages they link to sit at 53–76. Same site, same internal linking, same technical setup. Google appears far more willing to rank an explanation than a utility. I now treat the articles as the front door and the tools as what happens after.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search Console hides most of your queries.&lt;/strong&gt; My HEIC article shows 358 impressions at the page level, but the named queries for it sum to about 18. The rest is anonymised low-volume long tail that GSC won't show you. If you optimise only against the queries you can see, you're optimising against a few percent of reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One referring domain is a hard ceiling.&lt;/strong&gt; I have five dofollow backlinks and all five are from dev.to. Google counts referring domains, not links, so functionally I have one. Everything sitting at positions 11 to 21 is stuck there because of that, not because of the content. Directories are all nofollow or paid. AlternativeTo rejected the site for lack of notability. This is the actual constraint and I don't have a solution yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google Trends corrected a bet I'd already made.&lt;/strong&gt; Keyword Planner showed AVIF conversion volume down 90%, which I assumed was a measurement artifact after a 2024 spike. I checked properly. Against &lt;code&gt;heic to jpg&lt;/code&gt; on a five-year normalised series, &lt;code&gt;avif to jpg&lt;/code&gt; peaked at 8 in December 2024 and now sits at 4: down 50% from peak, down 33% year on year, still sliding, corroborated by &lt;code&gt;avif file to jpg&lt;/code&gt; at -80% in related queries. So I was half right and it didn't matter. AVIF is now a maintenance asset, not a growth area. I'd already shipped the pages. Cheap lesson, but it should have come before the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself in May
&lt;/h2&gt;

&lt;p&gt;Competition selection dominates execution quality for a new domain. I don't mean that as a slogan. I mean that the same team, the same stack and the same content quality produced position 87 in one topic and position 9 in another, in the same month, and the only variable was who else was in the room.&lt;/p&gt;

&lt;p&gt;If you're building anything that depends on search, look at your Search Console positions before you look at anything else. Not clicks, not impressions. Positions. If your pages sit in the eighties, no amount of writing better will fix it, because writing better was never the problem. Change the room.&lt;/p&gt;

&lt;p&gt;I'll post the next set of numbers in a few months, including if it goes badly. I set a kill trigger before starting — under 500 sessions a month by November means I reassess, and I've written it down publicly now specifically so it's harder to quietly move.&lt;/p&gt;

&lt;p&gt;The site is &lt;a href="https://fileoholic.com" rel="noopener noreferrer"&gt;fileoholic.com&lt;/a&gt; if you want to look at what these numbers describe. Happy to answer questions about any of it, including the parts that aren't working.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>indiehackers</category>
      <category>startup</category>
    </item>
    <item>
      <title>I built a HEIC to JPG converter that never uploads your photos (WebAssembly + libheif)</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Tue, 14 Jul 2026 06:08:42 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/i-built-a-heic-to-jpg-converter-that-never-uploads-your-photos-webassembly-libheif-291m</link>
      <guid>https://dev.to/ritusmoikaushik/i-built-a-heic-to-jpg-converter-that-never-uploads-your-photos-webassembly-libheif-291m</guid>
      <description>&lt;p&gt;&lt;em&gt;The privacy problem with "online HEIC converters", and how running libheif in the browser fixes it.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;If you've ever helped a non-technical person get an iPhone photo to open on a Windows laptop, you've hit HEIC. Since iOS 11, iPhones save photos as &lt;code&gt;.heic&lt;/code&gt; by default — roughly half the size of JPG at the same quality — and the rest of the computing world still chokes on it. Windows shows a black thumbnail, upload forms reject the extension, older apps just shrug.&lt;/p&gt;

&lt;p&gt;The usual fix is to Google "HEIC to JPG converter", click the first result, and upload your photos to a server you know nothing about. That's the part that always bothered me. A camera roll isn't holiday snaps — it's ID scans, medical documents, kids, things people photographed &lt;em&gt;because&lt;/em&gt; they were private. Shipping those to a stranger's box for a format conversion, then trusting an unverifiable "deleted after 1 hour" banner, is a bad trade.&lt;/p&gt;

&lt;p&gt;So I built the converter to run &lt;strong&gt;entirely in the browser&lt;/strong&gt; — no upload, no server, nothing leaves the device. Here's how it works and what tripped me up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea: decode HEIC where the file already is
&lt;/h2&gt;

&lt;p&gt;HEIC is HEVC-compressed image data in a container. Browsers can't decode it natively (that's the whole problem), so you ship the decoder yourself. Under the hood that's &lt;a href="https://github.com/strukturag/libheif" rel="noopener noreferrer"&gt;libheif&lt;/a&gt; compiled to WebAssembly — I use the &lt;a href="https://github.com/alexcorvi/heic2any" rel="noopener noreferrer"&gt;&lt;code&gt;heic2any&lt;/code&gt;&lt;/a&gt; wrapper around it, which handles the decode-and-re-encode in one call.&lt;/p&gt;

&lt;p&gt;The flow is entirely client-side — no &lt;code&gt;fetch&lt;/code&gt;, no upload endpoint, no backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;File (.heic)
  → heic2any (libheif WASM) decodes + re-encodes
  → Blob (image/jpeg or image/png)
  → URL.createObjectURL → download
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can prove it: open DevTools → Network tab, drop a file, and watch nothing happen. The page loads, then silence.&lt;/p&gt;

&lt;p&gt;The whole conversion is a few lines. Two decisions matter: &lt;strong&gt;lazy-load the decoder&lt;/strong&gt; and &lt;strong&gt;process files one at a time&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_INPUT_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Reject oversized files up front — HEIC decode is memory-heavy (see below)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tooBig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;files&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="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_INPUT_BYTES&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;tooBig&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;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tooBig&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="s2"&gt; is over 25MB.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Dynamic import: the WASM decoder only downloads when someone actually&lt;/span&gt;
  &lt;span class="c1"&gt;// converts — it stays out of the initial page bundle entirely.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heic2any&lt;/span&gt; &lt;span class="o"&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;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;heic2any&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="k"&gt;default&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="c1"&gt;// Sequential, NOT Promise.all — a parallel batch blows the heap (see below)&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&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;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;heic2any&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;toType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;outputFormat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// "image/jpeg" | "image/png"&lt;/span&gt;
      &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// slider, defaults to 0.9&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;finalBlob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// heic2any can return Blob[]&lt;/span&gt;
    &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalBlob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&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;
  
  
  Three things that tripped me up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The first conversion is slow — and that's fine.&lt;/strong&gt; The libheif WASM module is chunky (over a megabyte). Because it's behind a dynamic &lt;code&gt;import()&lt;/code&gt;, it only downloads the first time someone actually converts — it never touches the initial page load — and the browser caches it after that, so every conversion afterward feels instant. Compared to the typical converter site loading 4–6 MB of ad scripts before you can even drop a file, one lazy-loaded module is a good deal. I just made the "first HEIC takes longer (decoder loading)" state explicit in the UI so the first hit doesn't feel broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Memory, not CPU, is the real ceiling.&lt;/strong&gt; Decoding HEIC allocates a full RGBA bitmap — width × height × 4 bytes. A 48 MP photo is ~190 MB decoded, before you've even re-encoded it. On desktop, fine. On a mobile Safari tab with a hard memory cap, big files kill the tab. So I cap input at 25 MB per file and run the batch &lt;strong&gt;sequentially&lt;/strong&gt; — the &lt;code&gt;for … await&lt;/code&gt; loop above, not &lt;code&gt;Promise.all&lt;/code&gt; — so ten files don't allocate ten bitmaps at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Lossy → lossy needs a quality escape hatch.&lt;/strong&gt; HEIC → JPG re-encodes already-decoded pixels into another lossy format. At &lt;code&gt;quality: 0.9&lt;/code&gt; it's visually identical for photos; below ~0.8 you start seeing it on smooth gradients (sky, skin). I exposed a quality slider and default it to 90, and offer PNG for anyone who wants zero loss after the decode step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother when Apple's "Most Compatible" setting exists
&lt;/h2&gt;

&lt;p&gt;You can tell an iPhone to save JPG going forward (Settings → Camera → Formats → Most Compatible), and everyone should. But that does nothing for the thousands of HEICs already in the camera roll — those still need converting, and that's the job this tool does without asking anyone to upload anything.&lt;/p&gt;

&lt;p&gt;If you want the deeper non-technical version of the HEIC-on-Windows mess — the black-thumbnail bug, the Microsoft extensions, why some files won't open even after you install everything — I wrote that up here: &lt;a href="https://fileoholic.com/blog/heic-files-windows/" rel="noopener noreferrer"&gt;Why HEIC files won't open on Windows&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And the tool itself, if you want to poke at it (or watch the Network tab stay empty): &lt;a href="https://fileoholic.com/tools/heic-to-jpg/" rel="noopener noreferrer"&gt;HEIC to JPG converter&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;For a whole class of "convert this file" tools, the server is a liability, not a feature. WASM builds of the underlying C libraries — libheif, libvips, mozjpeg, pdfium — are good enough now that the file never has to leave the device. Faster for the user, zero privacy surface for you, and no infra to run. That's the direction I'm taking the rest of the tools too.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What other file conversions are you still uploading to a server that could run locally? Curious what people are stuck on.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>Why Your iPhone Photos Won't Open on Windows (and the 5-Second Fix)</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Fri, 10 Jul 2026 08:57:07 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/why-your-iphone-photos-wont-open-on-windows-and-the-5-second-fix-55g9</link>
      <guid>https://dev.to/ritusmoikaushik/why-your-iphone-photos-wont-open-on-windows-and-the-5-second-fix-55g9</guid>
      <description>&lt;p&gt;&lt;strong&gt;Your phone saves them as HEIC. Your laptop shrugs. Here's what's actually going on — and how to stop fighting it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;About once a week, someone I know forwards me the same panicked message. iPhone in one hand, Windows laptop open in front of them, and a photo called something like &lt;code&gt;IMG_4821.heic&lt;/code&gt; that just… won't open. They double-click it. Nothing. They email it to themselves and try again. Same shrug from Windows. By the time they message me, they're half-convinced the photo is corrupted or their laptop is broken.&lt;/p&gt;

&lt;p&gt;It's neither. The photo is fine. The laptop is fine. What you're running into is a quiet decade-old standoff between Apple and the rest of the computing world, and once you understand it, the fix takes about five seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-sentence version of what's happening
&lt;/h2&gt;

&lt;p&gt;Since 2017, iPhones save photos as HEIC instead of JPG by default. HEIC is a genuinely better format — roughly half the file size at the same visible quality — which is why your 256 GB phone lasts as long as it does. The catch: Windows, most web upload forms, older email clients, government portals, and a long tail of everyday software never fully caught up. So the moment a HEIC file leaves the Apple ecosystem, it hits a wall.&lt;/p&gt;

&lt;p&gt;That's the whole problem. Not malware, not corruption, not your fault. Just a format your iPhone loves and your laptop doesn't recognize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Windows specifically chokes on it
&lt;/h2&gt;

&lt;p&gt;Windows didn't support HEIC natively until late in the Windows 10 cycle, and even then you had to go install an extension from the Microsoft Store to get it working. Windows 11 looks like it should handle HEIC out of the box — it ships with partial support — but in practice you'll still often see a black or blank thumbnail in Explorer, or the Photos app throwing an "unsupported format" error.&lt;/p&gt;

&lt;p&gt;And here's the part that trips people up even after they fix the viewing problem: &lt;strong&gt;being able to view a HEIC file and being able to upload one are two completely different things.&lt;/strong&gt; You can install every Microsoft extension there is, get gorgeous HEIC previews in Explorer, and still have a job-application form or a bank's KYC portal reject your &lt;code&gt;.heic&lt;/code&gt; on the spot — because those forms check the file extension, not whether your operating system happens to have a decoder. For anything you need to send or upload, JPG is still the only format that works everywhere, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #1: Stop the bleeding at the source
&lt;/h2&gt;

&lt;p&gt;If you regularly share photos with Windows users or upload to forms, the cleanest long-term fix is to tell your iPhone to just save JPG from now on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Settings → Camera → Formats → Most Compatible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every new photo from that point forward saves as a standard JPG. Old photos stay as HEIC, but you stop creating new ones. You give up a little of that storage efficiency, but for most people the "it just opens anywhere" tradeoff is worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix #2: Convert the HEIC photos you already have
&lt;/h2&gt;

&lt;p&gt;The setting above only helps going forward. For the photos already sitting in your camera roll, you need to convert them — and this is where most people make a small mistake that I'd rather you avoid.&lt;/p&gt;

&lt;p&gt;The typical move is to Google "HEIC to JPG converter," click the first result, and upload your photos to some random server for conversion. For holiday snaps, fine. But think about what's usually in a camera roll: ID scans, medical documents, kids, things you took a photo of specifically &lt;em&gt;because&lt;/em&gt; they were private. Uploading those to a stranger's server for a format conversion, and trusting an unverifiable "deleted after one hour" promise, is a worse deal than people realize.&lt;/p&gt;

&lt;p&gt;You don't have to. Modern browsers are fast enough to do the entire conversion locally, on your own device, without a single byte leaving your machine. I've been using &lt;a href="https://fileoholic.com/tools/heic-to-jpg/" rel="noopener noreferrer"&gt;Fileoholic's HEIC to JPG converter&lt;/a&gt; for exactly this — you drop the file in, it converts in the browser tab using a local WebAssembly decoder, and you download the JPG. No upload, no signup, no daily limit. If you're the paranoid type (healthy instinct), open your browser's DevTools, watch the Network tab, and drop a file: you'll see the page load and then silence. Your photo never goes anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest take
&lt;/h2&gt;

&lt;p&gt;Apple made the right &lt;em&gt;technical&lt;/em&gt; call with HEIC. They arguably made the wrong &lt;em&gt;product&lt;/em&gt; call by flipping every iPhone to it by default in 2017, years before Windows, Android, and the web were ready. We've all been quietly cleaning up that mismatch ever since — which is why "convert HEIC to JPG" remains one of the most-searched file-format questions on the internet.&lt;/p&gt;

&lt;p&gt;If you want the deeper version of all this — the Windows 11 quirks, the black-thumbnail bug, the Microsoft extensions, and why some HEIC files still won't open even after you install everything — I wrote a full walkthrough here: &lt;a href="https://fileoholic.com/blog/heic-files-windows/" rel="noopener noreferrer"&gt;Why HEIC files won't open on Windows (and the simple fix)&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But if you just need the photo to open right now: change the camera setting, convert the ones you already have in your browser, and get on with your day. It's a five-second tax for an inbox that just works.&lt;/p&gt;




</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Billing a Bulk Endpoint Where Any Row Might Be Free</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Fri, 10 Jul 2026 07:35:32 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/billing-a-bulk-endpoint-where-any-row-might-be-free-4kik</link>
      <guid>https://dev.to/ritusmoikaushik/billing-a-bulk-endpoint-where-any-row-might-be-free-4kik</guid>
      <description>&lt;p&gt;I already had per-invoice billing working: reserve a credit, do the expensive call, settle or refund. Then I added a bulk endpoint — paste a list, verify them all — and the neat single-item model fell apart, because in a batch some rows cost money and some are free, and you don't know which is which until you've done the work.&lt;/p&gt;

&lt;p&gt;This is the metering design behind the bulk GSTIN verification tool on &lt;a href="https://gstextract.com" rel="noopener noreferrer"&gt;GSTExtract&lt;/a&gt; — a solo GST SaaS — and the two bugs I hit building it. The interesting part isn't the happy path. It's that "charge for a batch" quietly becomes "charge for the subset of the batch that turned out to be billable, without overselling and without spending the paid API on rows the user can't pay for."&lt;/p&gt;

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

&lt;p&gt;A user pastes up to 50 GST numbers. For each, I call a paid third-party registry API and return the business name, status, and address. Each fresh API call costs me real money, so the rule is &lt;strong&gt;1 credit per fresh successful lookup&lt;/strong&gt;. But three kinds of rows are free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cached&lt;/strong&gt; — I cache every result for 30 days. A repeat lookup of the same number costs me nothing, so it costs the user nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not found&lt;/strong&gt; — the API has no record. No data delivered, no charge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalid format&lt;/strong&gt; — rejected by a regex before any call. Obviously free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a "50-item" request might bill for 12. And I can't know the 12 up front — cached vs fresh I can tell, but found vs not-found needs the call. That's the whole problem in one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the single-item model doesn't lift
&lt;/h2&gt;

&lt;p&gt;My invoice path uses the classic &lt;strong&gt;reserve → settle → refund&lt;/strong&gt;: reserve one credit before the vision-model call, settle the real count after, refund on failure. For a batch, the temptation is to do it once for the whole thing: read balance, fetch everyone, charge the found count at the end.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;bal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;to_fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;bal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;              &lt;span class="c1"&gt;# cap to what they can afford
&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_fetch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;found&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow_partial&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;-- the hole
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This oversells. Two concurrent requests both read &lt;code&gt;bal = 10&lt;/code&gt;, both fetch 10, both try to charge — the first charges 10, the second charges 0 because &lt;code&gt;allow_partial&lt;/code&gt; floors at zero, and that second user just got 10 paid API calls for free. It's the exact race the single-item path was designed to avoid, reintroduced the moment I batched it. Read-then-write is not atomic no matter how you dress it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reserve per item, refund the misses
&lt;/h2&gt;

&lt;p&gt;The fix is to keep the atomic reserve, but do it &lt;strong&gt;per GSTIN&lt;/strong&gt;, and refund the ones that turn out free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gstinbulk:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="c1"&gt;# atomic reserve of 1 credit — a single conditional UPDATE, gated on rowcount,
&lt;/span&gt;    &lt;span class="c1"&gt;# so two concurrent reserves for the last credit can't both win
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;reserve_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&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;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deferred&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# out of credits — don't call the API
&lt;/span&gt;    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                          &lt;span class="c1"&gt;# bound API concurrency
&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="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;g&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;charged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;               &lt;span class="c1"&gt;# keep the reserved credit
&lt;/span&gt;    &lt;span class="nf"&gt;refund_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                      &lt;span class="c1"&gt;# not-found / failed = free
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;g&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;free&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each item reserves before its own paid call. A reserve failure means the balance is gone — that row is deferred, and crucially &lt;strong&gt;no API call is made for it&lt;/strong&gt;, so you never burn the paid quota on a row nobody paid for. Found rows keep the reserve. Everything else refunds. The billable total is just the count of &lt;code&gt;"charged"&lt;/code&gt;, and it's correct under concurrency because the atomicity lives in the credit reserve, not in a Python-side balance read.&lt;/p&gt;

&lt;p&gt;The reserve itself is a conditional update, not a read-modify-write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;credit_lots&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;lot&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gate on &lt;code&gt;rowcount&lt;/code&gt;: exactly one of two racing reserves for the last credit gets &lt;code&gt;rowcount = 1&lt;/code&gt;; the other gets &lt;code&gt;0&lt;/code&gt; and is told it's out of credits. SQLite serializes writers, so this holds without an explicit lock.&lt;/p&gt;

&lt;p&gt;One more subtlety: the refund on a not-found must &lt;strong&gt;not&lt;/strong&gt; feed the abuse counter. My invoice path counts a refunded failure toward a per-user daily "unreadable file" budget — a junk-upload guard. A not-found GSTIN is a perfectly normal result, not abuse, so bulk uses a plain reserve-release, not the failure-counting refund. Same primitive, different accounting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that actually cost me an hour
&lt;/h2&gt;

&lt;p&gt;My first version ran the reserve &lt;em&gt;inside&lt;/em&gt; &lt;code&gt;asyncio.to_thread&lt;/code&gt;, alongside the fetch, thinking "offload all the blocking work." The tests immediately threw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sqlite3.InterfaceError: bad parameter or other API misuse
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reserves were now happening on worker threads, several at once, against one SQLite connection. A SQLite connection isn't safe to share across threads like that. The fetch — an HTTP call — genuinely belongs in a thread. The DB writes do not.&lt;/p&gt;

&lt;p&gt;The fix is a one-line conceptual rule: &lt;strong&gt;DB stays on the event loop; only the slow HTTP call is offloaded.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;reserve_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;     &lt;span class="c1"&gt;# sync, on the loop — fast local write
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deferred&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;:&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="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# only this leaves the loop
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reserves are quick local writes; running them on the loop serializes them naturally and keeps them single-threaded, which is exactly what SQLite wants. The concurrency I actually needed was for the network calls, and those are the only thing that fans out now. This mirrors how the invoice path already worked — I'd just forgotten why it worked when I reached for &lt;code&gt;to_thread&lt;/code&gt; on the DB.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free tier, and one export gotcha
&lt;/h2&gt;

&lt;p&gt;Anonymous users get a small daily cap of free fresh lookups (for the SEO/demo value), metered per IP; cached hits don't count, so re-checking the same list is always free. Logged-in users draw from credits as above.&lt;/p&gt;

&lt;p&gt;And a non-billing one worth flagging: the results export to an &lt;code&gt;.xlsx&lt;/code&gt;, and the values include names pulled from the registry plus, in the export path, client-submitted rows. A cell value starting with &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;-&lt;/code&gt;, or &lt;code&gt;@&lt;/code&gt; is a formula-injection vector when the file is opened. Prefix those with a quote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"'"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cheap, and it turns a "download to Excel" feature into one that can't ship a live formula from untrusted input.&lt;/p&gt;

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

&lt;p&gt;"Meter a batch" is three problems wearing one coat: the count is unknown until the work runs, some of the work is free, and it all happens concurrently. Reserve per item and the three collapse into one — each row gates itself, pays for itself, or refunds itself, and the total falls out correct. Keep the DB writes on the loop and let only the network fan out.&lt;/p&gt;

&lt;p&gt;If you want to see the endpoint from the outside, it's the &lt;a href="https://gstextract.com/bulk-gstin-verification" rel="noopener noreferrer"&gt;bulk GSTIN verification tool&lt;/a&gt;; the invoice-extraction engine it sits next to is &lt;a href="https://github.com/ritusmoikaushik/gstextract-core" rel="noopener noreferrer"&gt;open source&lt;/a&gt;. The billing primitives are the same ones from &lt;a href="https://dev.to/ritusmoikaushik/metering-a-paid-api-without-overselling-the-credit-ledger-behind-a-solo-saas-3jej"&gt;the earlier write-up on the single-item ledger&lt;/a&gt; — this is what happened when I pointed them at a batch.&lt;/p&gt;

</description>
      <category>python</category>
      <category>sqlite</category>
      <category>asyncio</category>
      <category>saas</category>
    </item>
    <item>
      <title>Metering a Paid API Without Overselling: the Credit Ledger Behind a Solo SaaS</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Tue, 16 Jun 2026 07:10:36 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/metering-a-paid-api-without-overselling-the-credit-ledger-behind-a-solo-saas-3jej</link>
      <guid>https://dev.to/ritusmoikaushik/metering-a-paid-api-without-overselling-the-credit-ledger-behind-a-solo-saas-3jej</guid>
      <description>&lt;p&gt;I charge per invoice extracted. One upload of a five-invoice PDF should cost five credits, a failed extraction should cost nothing, and a user with one credit left must never get two. That last rule is where it got interesting.&lt;/p&gt;

&lt;p&gt;This is the billing design behind &lt;a href="https://gstextract.com" rel="noopener noreferrer"&gt;GSTExtract&lt;/a&gt; — a tool that reads Indian GST invoice PDFs into Excel — and the concurrency bug a second-pass audit found in it. Writing it up because "meter a paid API correctly" is one of those problems that looks trivial until you hold it up to the light.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;Each extraction calls a vision model, which costs me real money. So the user spends a credit per invoice. The catch: I don't know how many invoices are in a PDF until &lt;em&gt;after&lt;/em&gt; the model reads it. A single page can hold three invoices; a "10-page" file might be one. I can't charge up front because I don't know the count, and I can't charge after without a window where a user with a zero balance has already burned my API budget.&lt;/p&gt;

&lt;p&gt;The answer most billing systems land on is &lt;strong&gt;reserve → settle → refund&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reserve&lt;/strong&gt; one credit before doing the expensive work. This is the gate — if it fails, the user is out of credits and nothing runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settle&lt;/strong&gt; after success: charge the &lt;em&gt;actual&lt;/em&gt; count minus the one already reserved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refund&lt;/strong&gt; the reserve if the extraction failed, so failures are never billed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Money and credits are integers. Every change is also written to an append-only ledger with an idempotency key, so a retry can never double-charge. Balance is derived: the sum of remaining credits across non-expired "lots" (a lot is one grant — a signup bonus, a purchase). Consumption is FIFO by soonest expiry, so credits get used before they lapse.&lt;/p&gt;

&lt;p&gt;That part worked. The bug was in the word "atomic."&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: two uploads, one credit
&lt;/h2&gt;

&lt;p&gt;The original charge function did the obvious thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# read the balance
&lt;/span&gt;&lt;span class="n"&gt;bal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;current_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bal&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="c1"&gt;# ... then walk the lots and decrement in Python
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lot&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lots&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;take&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;take&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;-- writes an ABSOLUTE value
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the balance, decide, write the new value. Single-threaded, this is fine. Now picture a user with exactly &lt;strong&gt;one&lt;/strong&gt; credit firing two uploads at the same moment (a double-click, or two tabs):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request A reads &lt;code&gt;remaining = 1&lt;/code&gt;. Decides it's allowed.&lt;/li&gt;
&lt;li&gt;Request B reads &lt;code&gt;remaining = 1&lt;/code&gt;. Also decides it's allowed.&lt;/li&gt;
&lt;li&gt;A writes &lt;code&gt;remaining = 0&lt;/code&gt;, inserts a &lt;code&gt;-1&lt;/code&gt; ledger row.&lt;/li&gt;
&lt;li&gt;B writes &lt;code&gt;remaining = 0&lt;/code&gt;, inserts a &lt;code&gt;-1&lt;/code&gt; ledger row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both succeed. The user spent one credit and got two extractions. The idempotency key didn't save me — the two requests had &lt;em&gt;different&lt;/em&gt; keys, because they're two genuinely different uploads. The read-then-write-absolute is the classic lost-update race.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: let the database do the deciding
&lt;/h2&gt;

&lt;p&gt;The cure is to stop computing the new value in Python and let the write itself be the gate — a conditional &lt;code&gt;UPDATE&lt;/code&gt; that only fires if there's still enough left, checked under the row lock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreditLot&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreditLot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;lot_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CreditLot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;take&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CreditLot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;take&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="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;taken&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;take&lt;/span&gt;      &lt;span class="c1"&gt;# we actually got it
# rowcount 0 → someone else took it first; move on or roll back
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;remaining = remaining - take&lt;/code&gt; instead of &lt;code&gt;remaining = &amp;lt;number I computed&amp;gt;&lt;/code&gt;. The &lt;code&gt;WHERE remaining &amp;gt;= take&lt;/code&gt; re-checks the balance &lt;em&gt;at write time&lt;/em&gt;, and SQLite serializes writers, so the second request blocks, re-evaluates against the now-committed &lt;code&gt;remaining = 0&lt;/code&gt;, gets &lt;code&gt;rowcount = 0&lt;/code&gt;, and is correctly told it can't reserve. Exactly one upload wins. Balance can never go negative.&lt;/p&gt;

&lt;p&gt;I proved it with a test that fires N concurrent reservers at a one-credit account on a real file-backed SQLite DB (WAL mode, busy timeout — same as production, because an in-memory shared-connection DB won't reproduce writer contention). Exactly one succeeds, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second bug: a retry that costs me money
&lt;/h2&gt;

&lt;p&gt;There was a sneakier one, found in the same audit. The workspace retries an upload once on a dropped connection, reusing the same &lt;code&gt;request_id&lt;/code&gt; so it doesn't double-charge. And it didn't double-charge — the ledger keys saw to that. But the idempotency cache was written &lt;em&gt;after&lt;/em&gt; the model call, not before. So a retry that arrived while the first call was still in flight sailed past the cache check and fired a &lt;strong&gt;second&lt;/strong&gt; vision-model call. No double charge, but I paid for two API calls and logged a duplicate usage event.&lt;/p&gt;

&lt;p&gt;Fix: mark the request in-flight &lt;em&gt;before&lt;/em&gt; the expensive call, while everything above it is still synchronous (so two requests can't both pass the check), and have a retry return &lt;code&gt;409&lt;/code&gt; instead of re-running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_REQUESTS&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;rid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cached&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_flight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_progress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;409&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="c1"&gt;# ... reserve the credit ...
&lt;/span&gt;&lt;span class="n"&gt;_REQUESTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_flight&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# before the model call
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson that keeps repeating: &lt;strong&gt;idempotency has to cover the side effect, not just the database row.&lt;/strong&gt; The charge was idempotent. The expensive API call wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an append-only ledger earns its keep
&lt;/h2&gt;

&lt;p&gt;The thing that made all of this debuggable: nothing is ever updated or deleted in the transaction log. Every grant, charge, and refund is an immutable row with a signed delta. That gives you a reconciliation invariant you can assert on a schedule:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;the sum of all ledger deltas for a user == the sum of &lt;code&gt;remaining&lt;/code&gt; across all their lots&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If those two numbers ever disagree, a write went wrong, and an alert fires. (I also learned to &lt;em&gt;not&lt;/em&gt; fold a cache-freshness check into that alert — a balance cache that goes stale when a credit quietly expires is not corruption, and it had me chasing phantom "drift" emails for a day.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't read-modify-write a balance. Make the write conditional (&lt;code&gt;WHERE remaining &amp;gt;= n&lt;/code&gt;) and trust &lt;code&gt;rowcount&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Reserve → settle → refund cleanly separates "can they pay" from "what did it actually cost."&lt;/li&gt;
&lt;li&gt;Idempotency must guard the &lt;em&gt;expensive side effect&lt;/em&gt;, not only the ledger insert.&lt;/li&gt;
&lt;li&gt;An append-only ledger gives you a cheap, always-true invariant to reconcile against.&lt;/li&gt;
&lt;li&gt;Write the concurrency test against a real on-disk DB, or it won't reproduce the race you're trying to kill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The hosted tool has a free daily tier and twenty free credits on signup, with prepaid packs for heavier use — so this ledger runs on every extraction, and I'd rather it be boringly correct. If you want to see it in action, it's at &lt;a href="https://gstextract.com" rel="noopener noreferrer"&gt;gstextract.com&lt;/a&gt;; the extraction engine is &lt;a href="https://github.com/ritusmoikaushik/gstextract-core" rel="noopener noreferrer"&gt;open source&lt;/a&gt;, though the billing layer above is part of the hosted product.&lt;/p&gt;

&lt;p&gt;If you've metered a paid API differently — especially how you handle the count-unknown-until-after problem — I'd genuinely like to hear it.&lt;/p&gt;

</description>
      <category>python</category>
      <category>sqlite</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Open-Sourced the Core of My GST Tool. Here's What I Kept Private.</title>
      <dc:creator>Ritusmoi Kaushik</dc:creator>
      <pubDate>Tue, 21 Apr 2026 07:12:18 +0000</pubDate>
      <link>https://dev.to/ritusmoikaushik/i-open-sourced-the-core-of-my-gst-tool-heres-what-i-kept-private-37kk</link>
      <guid>https://dev.to/ritusmoikaushik/i-open-sourced-the-core-of-my-gst-tool-heres-what-i-kept-private-37kk</guid>
      <description>&lt;p&gt;A few days ago I published the core extraction engine of GSTExtract on GitHub, MIT licensed. The &lt;a href="https://github.com/ritusmoikaushik/gstextract-core" rel="noopener noreferrer"&gt;repository is here&lt;/a&gt;. It's not the full product. It's a seven-week-old snapshot, and that gap is the whole strategy.&lt;/p&gt;

&lt;p&gt;This post is about the decisions I made picking what to open-source and what to keep private. The deciding took longer than writing the code did, which surprised me. Writing it up in case anyone else is staring at the same question on their own project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context, Briefly
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://gstextract.com" rel="noopener noreferrer"&gt;GSTExtract&lt;/a&gt; reads Indian GST invoice PDFs and pulls out the fields (GSTIN, invoice number, amounts, taxes) into Excel. Small businesses and accountants use it at month-end to avoid hand-typing supplier bills into Tally. The stack is Python — pdfplumber for digital PDFs, Tesseract OCR as fallback, regex + keyword-anchored extraction, openpyxl for Excel output. FastAPI wraps it for the hosted version. The hosted tool is free during early access, with a paid tier planned for high-volume users later.&lt;/p&gt;

&lt;p&gt;So the question was: how do I open-source without giving away the commercial edge?&lt;/p&gt;

&lt;h2&gt;
  
  
  What Went Public
&lt;/h2&gt;

&lt;p&gt;The deterministic regex plus keyword extraction pipeline. GSTIN parsing with the Luhn mod-36 checksum. Zone segmentation that splits an invoice into header and footer and routes fields by zone. Tax-line detection with keyword anchoring. Excel export. The test suite. Basically, the commoditised parsing work that anyone doing invoice extraction would eventually re-implement themselves if they cared enough.&lt;/p&gt;

&lt;p&gt;If someone wants to understand &lt;em&gt;how&lt;/em&gt; you pull GSTINs and invoice totals out of a messy PDF, the code is all there. Forkable, patchable, usable in their own pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stayed Private
&lt;/h2&gt;

&lt;p&gt;The seven weeks of engine refinements since the cut point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-vendor accuracy tuning for Amazon, Flipkart, Swiggy, Zomato, BookMyShow, RedBus, and Myntra invoices (each one has a slightly different layout that breaks generic extraction)&lt;/li&gt;
&lt;li&gt;Table-based tax extraction for borderless PDFs&lt;/li&gt;
&lt;li&gt;Multi-invoice detection for combined bundles&lt;/li&gt;
&lt;li&gt;Per-field confidence scoring refinements&lt;/li&gt;
&lt;li&gt;Proprietorship invoice edge cases (handwritten-style templates that use bare "FROM" labels instead of "BILL FROM", single-digit invoice serials, and so on)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus the entire webapp: FastAPI layer, rate limiting, CSRF, file validation, the invoice validation gate that rejects credit notes and proforma bills, the batch upload flow, the learning-data logging. All of that stays closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Time-Lag
&lt;/h2&gt;

&lt;p&gt;This is the part I thought about most. Just publishing the latest engine would let anyone stand up a competing extraction site with the same accuracy I have. Publishing a snapshot from seven weeks ago means competitors get a working baseline, but not the current edge. The engineering work I do this month becomes public (maybe) in a few months, not immediately.&lt;/p&gt;

&lt;p&gt;Redis, MongoDB, and Elastic have all done versions of this. The open-core pattern: community gets a real, working version of the core. Commercial version stays ahead by some time delta. Nobody feels cheated, but the pricing power stays intact.&lt;/p&gt;

&lt;p&gt;For me, the specific cut was commit &lt;code&gt;0aa5f07&lt;/code&gt; from 2 March 2026, labeled "Phase 12 production hardening" in the private repo. Engine is solid from there. Everything after is refinement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Expect to Get From This
&lt;/h2&gt;

&lt;p&gt;Honestly, the single biggest thing is the backlink. GitHub has domain authority 96. Having a public repo that links to gstextract.com from the README gives my young domain an authority signal it couldn't easily get any other way. On a site that's only a couple of months old and trying to rank for competitive GST queries, that one dofollow link is legitimately meaningful.&lt;/p&gt;

&lt;p&gt;After that, in descending likelihood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Credibility signal to technical readers who want to see how the extraction works. Some fraction of people evaluating a hosted tool will check if the code is worth trusting.&lt;/li&gt;
&lt;li&gt;Issues and pull requests from people hitting edge cases I haven't seen. GST invoice formats are wilder than you'd think. Someone will inevitably send me a PDF from a vendor I've never heard of that breaks something.&lt;/li&gt;
&lt;li&gt;Forks from people who need self-hosted extraction for their own use cases. Rare, but they become potential collaborators.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I'm not expecting: hundreds of stars, viral adoption, a community forming around the repo. For a narrow Indian-GST tool, the realistic audience is small. A handful of serious users finding it and either using it or contributing is the bar, not hockey-stick open-source growth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Tradeoffs
&lt;/h2&gt;

&lt;p&gt;What this might cost me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Competitors can clone and study the extraction logic. They would eventually anyway, but I've shortened the path.&lt;/li&gt;
&lt;li&gt;Time maintaining the public version even if nobody uses it. If I ignore it for months, it rots and becomes a bad signal.&lt;/li&gt;
&lt;li&gt;Some fraction of potential paying customers might think "well, the core is free, I'll just self-host" and walk away. I don't think this is many people, but it's nonzero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I'm betting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The moat is distribution plus the continuous-improvement time-lag, not the code itself.&lt;/li&gt;
&lt;li&gt;Most competitors don't actually want to self-host and maintain a parser, deal with Tesseract and Poppler, manage their own uptime. Running the hosted tool is a service, not just code.&lt;/li&gt;
&lt;li&gt;People who'll pay for the hosted version are paying for the current-version edge, the no-setup convenience, and whatever the product becomes post-launch. The snapshot on GitHub is closer to educational than competitive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We'll see if that's right. If self-hosting forks start cutting into hosted usage in a measurable way, I'll revisit the cut-point. For now, the calculus feels fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/ritusmoikaushik/gstextract-core" rel="noopener noreferrer"&gt;github.com/ritusmoikaushik/gstextract-core&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Hosted tool: &lt;a href="https://gstextract.com" rel="noopener noreferrer"&gt;gstextract.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building something similar and thinking through an open-core cut, happy to compare notes. And if you run into parsing edge cases on unusual invoice formats, open an issue on the repo.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>python</category>
      <category>saas</category>
      <category>indiehackers</category>
    </item>
  </channel>
</rss>
