SQLite Queues: Replacing Kafka on a $5 VPS



 Replace Kafka with a production SQLite queue on a $5 VPS: batched CTE claims, WAL checkpoints, backoff, and dead-letter handling.


Kafka is a distributed log system. A $5 VPS background-job problem is usually not. For webhook retries, email dispatch, image processing, and other single-node workloads, a SQLite-backed queue can provide durable, at-least-once job delivery without brokers, partitions, or a second machine.

This is not a claim that SQLite outperforms Kafka at cluster scale. It is a claim that many teams install Kafka where a WAL-mode table and a disciplined worker would have been enough.



When the Replacement Is Honest


Good fit:

  • one primary node
  • tens to thousands of jobs per minute (and sometimes more with batching)
  • at-least-once processing with idempotent handlers
  • operational simplicity over multi-subscriber log fan-out
Poor fit:

  • many independent consumer groups across teams
  • multi-region replay and long retained streams
  • very high sustained multi-producer log traffic
  • strict cross-service event-bus semantics
If you need a distributed event bus, keep Kafka, Redpanda, or NATS. If you need reliable background work on one box, SQLite is a legitimate queue engine.



Failure Modes in Naive SQLite Queues


  1. Write contention (SQLITE_BUSY)
    BEGIN IMMEDIATE takes a reserved/write lock. Multiple claim workers without backoff will collide. The failure mode is not theoretical; it shows up as database is locked under modest concurrency.
  2. Unbatched claim IOPS waste
    One transaction per job ties throughput to single-row commit cost. On small VPS disks, that ceiling is much lower than CPU would suggest.
  3. Unbounded WAL growth
    Long-lived readers or missing checkpoints let queue.db-wal grow until the disk fills. On a 20 GB VPS this becomes an outage.
  4. Poison-pill retries
    A stale-lock reaper that only resets processing → pending can create an infinite crash loop. Dead-letter thresholds must be enforced in the data model.


Schema and PRAGMAs


PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA temp_store = MEMORY;

CREATE TABLE jobs (
  id           INTEGER PRIMARY KEY AUTOINCREMENT,
  queue        TEXT    NOT NULL DEFAULT 'default',
  payload      TEXT    NOT NULL,
  status       TEXT    NOT NULL DEFAULT 'pending', -- pending|processing|done|failed
  attempts     INTEGER NOT NULL DEFAULT 0,
  max_attempts INTEGER NOT NULL DEFAULT 5,
  available_at INTEGER NOT NULL,
  locked_at    INTEGER,
  locked_by    TEXT,
  created_at   INTEGER NOT NULL,
  error        TEXT
);

CREATE INDEX idx_jobs_poll
  ON jobs(queue, status, available_at, id)
  WHERE status = 'pending';

CREATE INDEX idx_jobs_reaper
  ON jobs(status, locked_at)
  WHERE status = 'processing';



WAL mode and busy_timeout are baseline requirements. See the SQLite docs on WAL and locking.



Throughput Model


Single-job claim throughput is bounded by lock + write + flush cost:

                                                          Tsingle=1/tlock+twrite+tflush



If the effective per-transaction cost is about 1 ms, the ceiling is near:

                                                           Tsingle≈1,000 jobs/sec 

Batching (B) claims into one transaction amortizes commit cost:

                                                      Tbatched=B⋅η/tlock+B⋅trow+tflush

With B = 50 and modest contention efficiency eta approx 0.95 , throughput can rise by an order of magnitude relative to single-row claims. Exact numbers depend on disk, payload size, and competing writers; the scaling shape is the important part.



Production Python Engine


import json
import random
import sqlite3
import time
import uuid
from typing import Any, Dict, List

class ProductionSQLiteQueue:
    def __init__(self, db_path: str = "queue.db", max_busy_timeout_ms: int = 5000):
        self.db_path = db_path
        self.worker_id = f"worker-{uuid.uuid4().hex[:8]}"
        self.max_busy_timeout_ms = max_busy_timeout_ms
        self._init_db()

    def _get_connection(self) -> sqlite3.Connection:
        conn = sqlite3.connect(
            self.db_path,
            isolation_level=None,
            timeout=self.max_busy_timeout_ms / 1000.0,
            check_same_thread=False,
        )
        conn.row_factory = sqlite3.Row
        conn.execute("PRAGMA journal_mode = WAL;")
        conn.execute("PRAGMA synchronous = NORMAL;")
        conn.execute(f"PRAGMA busy_timeout = {self.max_busy_timeout_ms};")
        conn.execute("PRAGMA temp_store = MEMORY;")
        return conn

    def _init_db(self) -> None:
        with self._get_connection() as conn:
            conn.execute("""
            CREATE TABLE IF NOT EXISTS jobs (
                id           INTEGER PRIMARY KEY AUTOINCREMENT,
                queue        TEXT    NOT NULL DEFAULT 'default',
                payload      TEXT    NOT NULL,
                status       TEXT    NOT NULL DEFAULT 'pending',
                attempts     INTEGER NOT NULL DEFAULT 0,
                max_attempts INTEGER NOT NULL DEFAULT 5,
                available_at INTEGER NOT NULL,
                locked_at    INTEGER,
                locked_by    TEXT,
                created_at   INTEGER NOT NULL,
                error        TEXT
            );
            """)
            conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_jobs_poll
            ON jobs(queue, status, available_at, id)
            WHERE status = 'pending';
            """)
            conn.execute("""
            CREATE INDEX IF NOT EXISTS idx_jobs_reaper
            ON jobs(status, locked_at)
            WHERE status = 'processing';
            """)

    def enqueue_batch(
        self,
        payloads: List[Dict[str, Any]],
        queue: str = "default",
        delay_sec: int = 0,
    ) -> None:
        now = int(time.time())
        available_at = now + delay_sec
        rows = [(queue, json.dumps(p), "pending", available_at, now) for p in payloads]

        conn = self._get_connection()
        try:
            conn.execute("BEGIN IMMEDIATE;")
            conn.executemany(
                """
                INSERT INTO jobs (queue, payload, status, available_at, created_at)
                VALUES (?, ?, ?, ?, ?)
                """,
                rows,
            )
            conn.execute("COMMIT;")
        except Exception:
            conn.execute("ROLLBACK;")
            raise
        finally:
            conn.close()

    def claim_batch(self, queue: str = "default", batch_size: int = 10) -> List[sqlite3.Row]:
        conn = self._get_connection()
        now = int(time.time())

        for attempt in range(5):
            try:
                conn.execute("BEGIN IMMEDIATE;")
                cur = conn.execute(
                    """
                    WITH claimable AS (
                        SELECT id FROM jobs
                        WHERE queue = ?
                          AND status = 'pending'
                          AND available_at <= ?
                        ORDER BY id ASC
                        LIMIT ?
                    )
                    UPDATE jobs
                    SET status = 'processing',
                        locked_at = ?,
                        locked_by = ?,
                        attempts = attempts + 1
                    WHERE id IN (SELECT id FROM claimable)
                    RETURNING *;
                    """,
                    (queue, now, batch_size, now, self.worker_id),
                )
                rows = cur.fetchall()
                conn.execute("COMMIT;")
                return rows
            except sqlite3.OperationalError as e:
                conn.execute("ROLLBACK;")
                msg = str(e).lower()
                if "locked" in msg or "busy" in msg:
                    # exponential backoff + jitter
                    time.sleep((2 ** attempt) * 0.01 + random.uniform(0, 0.05))
                else:
                    raise
            finally:
                if attempt == 4:
                    conn.close()
        conn.close()
        return []

    def ack_batch(self, job_ids: List[int]) -> None:
        if not job_ids:
            return
        placeholders = ",".join("?" * len(job_ids))
        conn = self._get_connection()
        try:
            conn.execute("BEGIN IMMEDIATE;")
            conn.execute(
                f"UPDATE jobs SET status = 'done' WHERE id IN ({placeholders})",
                job_ids,
            )
            conn.execute("COMMIT;")
        except Exception:
            conn.execute("ROLLBACK;")
            raise
        finally:
            conn.close()

    def nack_or_fail(self, job_id: int, error_msg: str, retry_delay: int = 30) -> None:
        conn = self._get_connection()
        now = int(time.time())
        try:
            conn.execute("BEGIN IMMEDIATE;")
            row = conn.execute(
                "SELECT attempts, max_attempts FROM jobs WHERE id = ?",
                (job_id,),
            ).fetchone()

            if row and row["attempts"] >= row["max_attempts"]:
                # Dead-letter / terminal failure
                conn.execute(
                    """
                    UPDATE jobs
                    SET status = 'failed',
                        error = ?,
                        locked_at = NULL,
                        locked_by = NULL
                    WHERE id = ?
                    """,
                    (error_msg, job_id),
                )
            else:
                conn.execute(
                    """
                    UPDATE jobs
                    SET status = 'pending',
                        error = ?,
                        locked_at = NULL,
                        locked_by = NULL,
                        available_at = ?
                    WHERE id = ?
                    """,
                    (error_msg, now + retry_delay, job_id),
                )
            conn.execute("COMMIT;")
        except Exception:
            conn.execute("ROLLBACK;")
            raise
        finally:
            conn.close()

    def reap_stale_locks(self, visibility_timeout_sec: int = 300) -> int:
        conn = self._get_connection()
        cutoff = int(time.time()) - visibility_timeout_sec
        try:
            conn.execute("BEGIN IMMEDIATE;")
            cur = conn.execute(
                """
                UPDATE jobs
                SET status = 'pending',
                    locked_at = NULL,
                    locked_by = NULL
                WHERE status = 'processing'
                  AND locked_at < ?
                RETURNING id;
                """,
                (cutoff,),
            )
            n = len(cur.fetchall())
            conn.execute("COMMIT;")
            return n
        except Exception:
            conn.execute("ROLLBACK;")
            raise
        finally:
            conn.close()

    def checkpoint_wal(self) -> None:
        conn = self._get_connection()
        try:
            conn.execute("PRAGMA wal_checkpoint(PASSIVE);")
        finally:
            conn.close()


SQLite Queues: Replacing Kafka on a $5 VPS




Worker Loop Recommendations


  • Claim in batches, process, then ack_batch.
  • Use jittered backoff on lock errors; do not spin.
  • Run reap_stale_locks() on an interval.
  • Run checkpoint_wal() periodically under write load.
  • Keep payloads small; store large blobs elsewhere and queue references.
  • Make handlers idempotent—at-least-once delivery implies duplicates after crashes.


Durability and Backup


synchronous = NORMAL with WAL is a common performance/durability balance for job queues. If every job is financial-critical, evaluate stronger durability settings and continuous backup. Tools such as Litestream can replicate SQLite WAL changes to object storage for low-cost DR on small VPS setups.



What You Give Up Relative to Kafka


  • independent consumer groups at org scale
  • long retained multi-subscriber logs
  • broker-level partition elasticity
  • mature multi-tenant event-bus tooling
Those are real capabilities. They are also unrelated to “send 5,000 password-reset emails.”



Operational Metrics


Track at minimum:
  • pending depth by queue
  • age of oldest pending job
  • claim wait / lock retries
  • failed DLQ rate
  • WAL size / checkpoint age
Depth without age hides stuck poison pills. Lock retries without depth hides write contention.



Trade-offs


  • single-node durability boundary unless replicated
  • write serialization under heavy multi-writer contention
  • not a cross-language enterprise bus by default
  • exactly-once requires idempotent consumers and careful side effects


Final Notes


Replacing Kafka with SQLite on a $5 VPS is sound when the problem is durable jobs, not distributed log transport. The production version is not “one table and a while loop.” It is batched claims, backoff under SQLITE_BUSY, WAL checkpoint discipline, visibility timeouts, and dead-letter thresholds.Get those mechanics right and SQLite becomes a boring, inexpensive queue that keeps the architecture proportional to the workload. Reach for Kafka when you need Kafka’s distributed properties—not as a default background-worker dependency.





Post a Comment

Previous Post Next Post