Back to Lab
RAXXO Studios 9 min read No time? Make it a 1 min read

SQLite is Enough: 5 RAXXO Services I Moved Off Postgres

Database
9 min read
TLDR
×
  • Moved 5 RAXXO services off Postgres + Neon to SQLite + better-sqlite3 in WAL mode
  • Local roundtrips dropped from 38ms to 0.4ms on the syndication tracker, 90x faster
  • Litestream replicates the .db file to S3 every 10s, recovery point under 60s
  • sqlite-vec handles 12k prompt-eval embeddings in one file, no pgvector cluster needed
  • Postgres still wins for multi-writer apps, real concurrency, and anything a team touches

I keep seeing solo devs reach for managed Postgres on day one. Neon, Supabase, RDS, whatever. I did it too. Most of my services did not need it. This year I moved 5 of them to SQLite and the bills, the latency, and the mental overhead all went down.

This is what got moved, what stayed on Postgres, and the actual numbers from production.

Why SQLite finally clicked for me in 2026

Three things changed for me. WAL mode is now the default I never turn off. Litestream made the "but what if the disk dies" objection mostly go away. And sqlite-vec landed stable, which removes the last reason I was running a separate vector DB.

The headline number for a one-person studio: a SQLite query on the same Fly machine as the app runs in roughly 0.2 to 0.6 milliseconds. A Neon serverless query from Frankfurt to the EU pooler averages 18 to 45 milliseconds depending on cold start. That is two orders of magnitude. For a workload where the only writer is a cron job and the only reader is me, I was paying that latency and a monthly bill for nothing.

The setup I use everywhere now:


import Database from 'better-sqlite3'
const db = new Database('data.db')
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
db.pragma('foreign_keys = ON')
db.pragma('busy_timeout = 5000')

Four lines. That is the entire connection setup. No pool, no SSL config, no env var soup. Compare that to a typical Drizzle plus Neon setup and you can see why I keep choosing SQLite for new internal services.

For backups, Litestream runs as a sidecar:


litestream replicate -config /etc/litestream.yml

It streams every WAL frame to S3 inside 10 seconds. I tested a full restore on the syndication tracker DB (340 MB). Pull from S3, replay WAL, app back online: 47 seconds. Good enough for tools that are not customer-facing.

Service 1: blog syndication tracker, 38ms to 0.4ms

This was the most obvious win. The tracker stores one row per article per platform: dev.to, Hashnode, Medium, dlvr.it, and a few RSS endpoints. About 2,400 rows total at 222 articles. Was Postgres on Neon with Drizzle. Now SQLite with `better-sqlite3` and raw SQL.

Schema is boring on purpose:


CREATE TABLE syndication (
  id INTEGER PRIMARY KEY,
  article_handle TEXT NOT NULL,
  platform TEXT NOT NULL,
  status TEXT NOT NULL,
  external_url TEXT,
  posted_at TEXT,
  error TEXT,
  UNIQUE(article_handle, platform)
);
CREATE INDEX idx_syndication_platform_status
  ON syndication(platform, status);

The hot query, "find articles not yet syndicated to platform X":


SELECT a.handle FROM articles a
LEFT JOIN syndication s
  ON s.article_handle = a.handle AND s.platform = ?
WHERE s.id IS NULL OR s.status = 'failed'
ORDER BY a.published_at DESC
LIMIT 50;

On Neon (cold start counted, EU pooler): mean 38ms over 200 runs. On SQLite local file: mean 0.42ms over the same 200 runs. The query itself is identical. The 90x gap is almost entirely network and TLS handshake.

What I gave up: nothing I missed. The tracker has one writer (a GitHub Action). It runs every 6 hours. There is no concurrent edit case to worry about.

Service 2: Shopify inventory watch agent state

This agent polls every 15 minutes and stores per-variant snapshots so it can detect drops and weird movements. About 480 variants across Lab Inventory blog stories and the live shop. 12 months of history is roughly 16 million rows. This was the one I was nervous about. SQLite handled it without breathing hard.

I split history into yearly attached databases using `ATTACH DATABASE`:


ATTACH DATABASE 'inventory_2025.db' AS y2025;
ATTACH DATABASE 'inventory_2026.db' AS y2026;

SELECT variant_id, qty, snapshot_at
FROM y2026.snapshots
WHERE variant_id = ?
  AND snapshot_at >= datetime('now', '-7 days')
ORDER BY snapshot_at DESC;

This is cheap horizontal sharding without an orchestrator. Old years are read-only. New writes only hit one file. The agent's 7-day rolling query stays under 3ms even when total rows pass 20 million.

Storage: 1.1 GB for 16M rows on disk after `VACUUM`. The same data on Neon was 4.2 GB billed, partly because of WAL retention I could not tune cheaply. That cut my Neon bill by about 14 EUR per month on its own.

Service 3: center-this-div leaderboard cache

The center-this-div game writes a leaderboard score every time someone finishes a level. It used to round-trip to Neon on every submission. Some users hit submit 30 times in a session because they kept improving.

The new path: SQLite as a write-through cache on the edge function instance, Litestream replicating to S3, and a scheduled job that aggregates the daily top 100 to a static JSON file served from the CDN.

The end-user latency dropped from 280ms median submit time to 24ms. The static leaderboard load is now zero ms of database time because it is just a JSON file behind Cloudflare. The "cache" is the source of truth for live scores. The static file is for the public view.

The honest tradeoff: if the Fly machine dies between the WAL flush and the next Litestream replication, I can lose up to 10 seconds of scores. For a free game leaderboard, that is fine. For payments it would not be.

Service 4: prompt eval runs and sqlite-vec

This is the one that surprised me. I run small prompt evals locally, maybe 50 to 200 prompt-output pairs per run, scored by a judge model. I want to embed every output and find the closest matches across runs.

Used to run pgvector on a Neon branch. Now everything lives in one `evals.db` with sqlite-vec:


CREATE VIRTUAL TABLE eval_embeddings USING vec0(
  output_id INTEGER PRIMARY KEY,
  embedding FLOAT[1536]
);

-- find the 5 closest outputs to a query embedding
SELECT output_id, distance
FROM eval_embeddings
WHERE embedding MATCH ?
ORDER BY distance
LIMIT 5;

12,400 embeddings at 1536 dimensions in one 78 MB file. KNN search returns in 8 to 14ms on my laptop. No separate service, no API key, no usage cap. The whole eval pipeline is a single SQLite file I can `scp` to a different machine and keep running.

For comparison, setting up Neon branching for ephemeral eval environments was elegant but added 2 minutes of branch-create time per run. The local SQLite version starts in milliseconds. For something I run 30 times a day, that compounds.

Service 5: napkin and garden audit history

This is the most personal one. My napkin (running notes) and the garden-walk audit reports both used to live in markdown files. I wanted historical state: what did the orphan list look like 3 months ago, which clusters grew the fastest, which warnings repeated.

Now there is `audit-history.db` with two tables:


CREATE TABLE napkin_snapshots (
  id INTEGER PRIMARY KEY,
  taken_at TEXT NOT NULL,
  body TEXT NOT NULL,
  hash TEXT NOT NULL UNIQUE
);

CREATE TABLE garden_walks (
  id INTEGER PRIMARY KEY,
  walked_at TEXT NOT NULL,
  orphans_count INTEGER,
  dead_ends_count INTEGER,
  total_articles INTEGER,
  payload TEXT
);

A cron snapshots the napkin every Monday and stores the full text. The garden-walk skill writes its result row at the end of each run. Six months in, I can ask "show me weeks where orphans went up by more than 5" with a 4-line query. That is something I could not see when the data was scattered across markdown files.

This file is 11 MB after a year. It will probably never need to be anything else. The whole archive fits in the same backup process as everything else: Litestream to S3, done.

Where I still keep Postgres

I want to be honest about this part because the blog discourse around SQLite is sometimes too clean.

Anything with multiple human writers stays on Postgres. That includes the Shopify backend any client touches, the remind.me side project (separate ecosystem entirely), and any service that needs row-level locking under real concurrency.

Anything that needs proper online schema migrations, partial indexes I tune monthly, or extensions like PostGIS or pgcrypto with hardware-accelerated paths, also stays on Postgres. SQLite has alternatives but they are not at parity yet.

And anything where the operational cost of "the disk died" is more than 10 minutes of my time. Customer-facing checkout state, anything financial, anything with a contractual SLA. Postgres on Neon with point-in-time recovery is the right answer there.

What I stopped doing is reaching for Neon by default for solo internal tools. The bar is now: do I have more than one writer, do I need true high availability, do I need a feature SQLite can't do. If all three are no, it is SQLite.

Bottom line

Five services moved, roughly 22 EUR per month off the bill, latency down by 50x to 100x on the queries I run most, and a backup story I actually trust because I can copy a single file. Nothing exotic, just SQLite with WAL, better-sqlite3, Litestream, and sqlite-vec.

If you are running a one-person studio or a small set of internal tools and you started on managed Postgres because that was the default advice in 2022, it is worth a weekend to see which of your services actually need it. Most of mine did not.

If you want the bigger toolset I run alongside this, the RAXXO Studio Lab overview lists every workflow I have written about, including the ones that still live on Postgres for good reason.

Stay in the loop
New tools, drops, and AI experiments. No spam. Unsubscribe anytime.
Back to all articles