<?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: Rajan Nishad</title>
    <description>The latest articles on DEV Community by Rajan Nishad (@rajan_nishad).</description>
    <link>https://dev.to/rajan_nishad</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%2F4089205%2F0b61a785-bbd7-4666-b02b-e2dac0ecd3d7.jpg</url>
      <title>DEV Community: Rajan Nishad</title>
      <link>https://dev.to/rajan_nishad</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rajan_nishad"/>
    <language>en</language>
    <item>
      <title>I used JSON files instead of a database for 1,699 records. Here's what actually broke.</title>
      <dc:creator>Rajan Nishad</dc:creator>
      <pubDate>Sat, 22 Aug 2026 08:58:34 +0000</pubDate>
      <link>https://dev.to/rajan_nishad/i-used-json-files-instead-of-a-database-for-1699-records-heres-what-actually-broke-4f1c</link>
      <guid>https://dev.to/rajan_nishad/i-used-json-files-instead-of-a-database-for-1699-records-heres-what-actually-broke-4f1c</guid>
      <description>&lt;p&gt;Three years ago I'd have called this a bad idea. I still might. But I've been running &lt;a href="https://ryzenstudy.com" rel="noopener noreferrer"&gt;ryzenstudy.com&lt;/a&gt; on flat JSON files for months now — 1,699 exam paper records, 699 distinct subject codes, five academic programmes, zero database servers — and the failures haven't been the ones anyone warned me about.&lt;/p&gt;

&lt;p&gt;Nobody warned me about slugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The setup&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's a Next.js 15 app on App Router, one VPS, PM2 in fork mode behind Nginx. The entire data layer is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAllPapers&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DB_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. That's the ORM. Every query is &lt;code&gt;Array.prototype.filter&lt;/code&gt;. Every write is &lt;code&gt;writeFileSync&lt;/code&gt;. There is no connection pool because there is no connection.&lt;/p&gt;

&lt;p&gt;Why? The data is append-mostly and read-heavy — students download old exam papers, nobody edits them. The whole file is under a megabyte. Postgres would have meant another process to keep alive, another backup story, another thing to be woken up by. &lt;code&gt;fs.readFileSync&lt;/code&gt; has never once paged me.&lt;/p&gt;

&lt;p&gt;For about eight months it just worked. Then I went looking for something else entirely and found three bugs stacked on top of each other, all of them a direct consequence of not having a database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: 95 papers that existed but had no URL
&lt;/h2&gt;

&lt;p&gt;Slugs are generated from the record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paper&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;slugify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subjectName&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;yr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`aktu-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;course&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;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;semester&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;name&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;yr&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Course, semester, subject name, year. Looks unique. It isn't.&lt;/p&gt;

&lt;p&gt;My university issues the &lt;em&gt;same subject&lt;/em&gt; under &lt;em&gt;different codes&lt;/em&gt; depending on which branch you're in. "Deep Learning" in seventh semester is &lt;code&gt;KCS078&lt;/code&gt; for computer science, &lt;code&gt;KDS078&lt;/code&gt; for the data science branch, and &lt;code&gt;KOT076&lt;/code&gt; if you take it as an open elective. Three separate exam papers. Three separate PDFs. One slug.&lt;/p&gt;

&lt;p&gt;I nearly deleted them. Sitting there in a duplicate report, three rows with the same course, same semester, same year, same subject name — every instinct said dedupe. I checked the files first, mostly out of paranoia:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;972a76fc5bb8  148916  KCS078_DEEP_LEARNING.pdf
38febefb87c7  143581  KOT076_DEEP_LEARNING.pdf
ad5608859a7e  145162  KDS078_DEEP_LEARNING.pdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Different hashes. Different sizes. Three real papers, and I'd been about to throw two of them away because my slug function was too narrow.&lt;/p&gt;

&lt;p&gt;A database would have caught this the first week. Not because databases are magic, but because I'd have been forced to declare a unique constraint, and the insert would have blown up in my face while I still remembered what the schema meant. With JSON there's no constraint to violate. The second record just sits there, perfectly valid, permanently unreachable.&lt;/p&gt;

&lt;p&gt;95 papers were in that state. Uploaded, stored, served by nothing.&lt;/p&gt;

&lt;p&gt;The fix is collision-aware slug generation — build an index of which record "owns" each base slug, and give everyone else a code-qualified URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;baseSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paper&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;primary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;slugPrimaryIndex&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="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;base&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;slugify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subjectCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`aktu-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;course&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;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;semester&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;code&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="nf"&gt;slugify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subjectName&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;yr&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;code === primary.code&lt;/code&gt; check is the part I got wrong on the first deploy, and it's the more interesting half of the story. Keep reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I introduced while fixing the bug
&lt;/h2&gt;

&lt;p&gt;First version didn't have that check. Ship it, run a sweep across every URL in the sitemap, and one comes back &lt;code&gt;308&lt;/code&gt; instead of &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One. Out of 1,615.&lt;/p&gt;

&lt;p&gt;Turns out my 147 colliding records were two completely different populations wearing the same costume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;95 records&lt;/strong&gt; had a genuinely different subject code. Real distinct papers. Code-qualified slug works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;52 records&lt;/strong&gt; had the &lt;em&gt;identical&lt;/em&gt; code as the record they collided with. Duplicate uploads, and a batch of placeholder rows whose IDs literally end in &lt;code&gt;-dummy&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For that second group the code suffix disambiguates nothing. Worse — my slug matcher accepts a legacy code-first URL format for backwards compatibility, so the &lt;em&gt;original&lt;/em&gt; record also answers to the new URL, wins the lookup, and the page 301s you back to where you started. My sitemap had started advertising redirects.&lt;/p&gt;

&lt;p&gt;If I'd trusted the deploy and skipped the sweep, that ships silently. The section renders. The pages load. Google just quietly gets 52 URLs that bounce.&lt;/p&gt;

&lt;p&gt;I now believe a full-sweep is non-optional after any URL-shaped change, and I don't think that belief is specific to flat files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: a homepage section that had been empty for weeks
&lt;/h2&gt;

&lt;p&gt;Different day, same root disease.&lt;/p&gt;

&lt;p&gt;The analytics file went through a schema migration a while back. Top-level counters moved down into a &lt;code&gt;legacy&lt;/code&gt; object, and a new &lt;code&gt;daily&lt;/code&gt; bucket started collecting per-day stats. The admin dashboard got updated. The homepage didn't.&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;// homepage — reads a key that no longer exists&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;downloads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;downloadsBySubject&lt;/span&gt; &lt;span class="o"&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;getAnalytics()&lt;/code&gt; returns &lt;code&gt;{ legacy, daily, visitedIPs }&lt;/code&gt;. There is no top-level &lt;code&gt;downloadsBySubject&lt;/code&gt;. So &lt;code&gt;downloads&lt;/code&gt; is &lt;code&gt;{}&lt;/code&gt;, forever. Every download count resolves to zero, the trending list filters to an empty array, and the component does what any sensible component does with an empty array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;trending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It renders nothing. No error. No warning. No log line. The section just stopped existing on the page and I didn't notice, because a missing section looks exactly like a section you forgot you built.&lt;/p&gt;

&lt;p&gt;The data was fine the whole time — 1,355 records in the legacy bucket, another 1,003 download events across 31 days in &lt;code&gt;daily&lt;/code&gt;. Tracking never broke. Only the read path did.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;|| {}&lt;/code&gt; is a lovely little footgun. It converts "this key is gone" into "this key is empty," and those two things want very different reactions from you.&lt;/p&gt;

&lt;p&gt;And while I was in there: the lookup summed wrong too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// picks the FIRST matching key and ignores the rest&lt;/span&gt;
&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;downloads&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;k&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One paper ID appears under three different key formats depending on which era of the code wrote it — bare ID, &lt;code&gt;id — name&lt;/code&gt;, and &lt;code&gt;id — name (code)&lt;/code&gt;. &lt;code&gt;.find()&lt;/code&gt; grabs one and shrugs at the others. My actual top paper had 114 downloads. The old logic reported 55 for a completely different paper.&lt;/p&gt;

&lt;p&gt;Schema drift in a typed database gives you a migration error. Schema drift in JSON gives you &lt;code&gt;undefined&lt;/code&gt;, and &lt;code&gt;undefined&lt;/code&gt; gives you a plausible-looking wrong answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: the one I saw coming
&lt;/h2&gt;

&lt;p&gt;This one I did anticipate, which is why it's the shortest section.&lt;/p&gt;

&lt;p&gt;Two requests writing the file at once will corrupt it. So writes go through a promise chain:&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;let&lt;/span&gt; &lt;span class="nx"&gt;writeQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;writeQueue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;writeQueue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;writeQueue&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;And the write itself is temp-file-plus-rename, so a crash mid-write leaves the old file intact rather than half a JSON document:&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;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ANALYTICS_FILE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.tmp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renameSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ANALYTICS_FILE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a daily snapshot to &lt;code&gt;.bak&lt;/code&gt;, and a parse-failure path that restores from it. That last part matters more than it sounds: the original code returned empty defaults when the parse failed, which meant one corrupt read would get written back as an empty file and eat the entire history. The recovery path was the bug.&lt;/p&gt;

&lt;p&gt;This works because it's one process. Scale to two PM2 instances and the queue is worthless — they don't share it. That's the actual ceiling on this architecture, and it's a hard one. Not row counts. Process counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The performance thing I walked into
&lt;/h2&gt;

&lt;p&gt;Making slug generation collision-aware means it needs to know about every other record. &lt;code&gt;getAllPapers()&lt;/code&gt; reads and parses the file on every call. The sitemap calls &lt;code&gt;generateSlug()&lt;/code&gt; 1,699 times.&lt;/p&gt;

&lt;p&gt;You can do that multiplication yourself.&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;let&lt;/span&gt; &lt;span class="nx"&gt;_slugIndex&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;_slugIndexKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;slugPrimaryIndex&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;st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DB_FILE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&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;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mtimeMs&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;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_slugIndex&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;_slugIndexKey&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;_slugIndex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...rebuild&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keyed on mtime and size rather than a TTL, so an upload through the admin panel invalidates it instantly and a script editing the file directly does too. &lt;code&gt;statSync&lt;/code&gt; is cheap next to read-plus-parse.&lt;/p&gt;

&lt;p&gt;This is a query planner. I wrote a bad query planner. That's what "no database" actually costs — not the storage, the accumulated re-implementation of things Postgres finished solving in 1998.&lt;/p&gt;

&lt;h2&gt;
  
  
  So was it the wrong call?
&lt;/h2&gt;

&lt;p&gt;Honestly, no. Not for this.&lt;/p&gt;

&lt;p&gt;The failures above cost me a day to find and fix. A Postgres instance would have cost me a day to set up and then a low background tax forever — migrations, backups, a connection dying at 3am. Read-heavy, append-mostly, sub-megabyte, one writer: flat files are a defensible answer, and I'd pick them again.&lt;/p&gt;

&lt;p&gt;But I'd change two things from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write the uniqueness check even when nothing enforces it.&lt;/strong&gt; Not a constraint — just a script that greps for records colliding on whatever your URL key is, and run it after every bulk upload. My 95 unreachable papers were detectable in about twelve lines of JavaScript from the very first day they existed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never let a read path fail into a plausible default.&lt;/strong&gt; &lt;code&gt;|| {}&lt;/code&gt; and &lt;code&gt;|| []&lt;/code&gt; hid two bugs from me. If a key you require is missing, that's not empty — that's broken, and it should say so out loud in dev.&lt;/p&gt;

&lt;p&gt;The site's live if you want to see what 1,615 JSON records look like served as pages — &lt;a href="https://ryzenstudy.com/aktu-previous-year-paper" rel="noopener noreferrer"&gt;Ryzenstudy&lt;/a&gt; is free, no login, built for students at my own university.&lt;/p&gt;

&lt;p&gt;If you're running something similar at a bigger scale, I'd genuinely like to know where your ceiling turned out to be. Mine looks like it's process count. I'm curious whether that's everyone's.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>nextjs</category>
      <category>java</category>
    </item>
  </channel>
</rss>
