DEV Community

the bomber
the bomber

Posted on

Using node:sqlite in Node 24 for Lightweight Express Services

Introduction

Hey, I am Kaleb. I run Dream Futures Tech out of Pompano Beach, Florida. Around my office and on the small automation server I keep running at home, I often need to spin up micro-services to handle simple tasks like logging webhook payloads, tracking internal uptime checks, or queueing small batch jobs. Historically, reaching for SQLite meant installing better-sqlite3 or sqlite3, which brings along native compilation steps and build tools. That is not a dealbreaker, but it is friction.

Node 24 ships with an experimental built-in SQLite module via node:sqlite. That means zero external dependencies for basic relational storage. In my experience, dropping external dependencies makes long-term maintenance significantly easier. Here is how I set up a small Express service using Node 24's native SQLite module for a local tracking tool.

Setting Up the Express and SQLite Foundation

First, make sure you are running Node 24. Since node:sqlite is built right into the runtime, your package.json stays remarkably clean. You only need Express.

{
  "name": "local-tracker",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "express": "^4.19.2"
  }
}
Enter fullscreen mode Exit fullscreen mode

I prefer keeping my local services minimal. Let's look at the basic entry point where we initialize Express and open our SQLite database connection. We import databaseSync from node:sqlite. This gives us a synchronous, straightforward API that fits nicely into small scripts where asynchronous connection setups add unnecessary complexity.

import express from 'express';
import { DatabaseSync } from 'node:sqlite';
import path from 'node:path';

const app = express();
app.use(express.json());

// Open a local database file in the project root
const dbPath = path.resolve(process.cwd(), 'events.db');
const database = new DatabaseSync(dbPath);

console.log(`Database connected at ${dbPath}`);
Enter fullscreen mode Exit fullscreen mode

Writing Database Initialization

Next, we need to create our tables if they do not already exist. Because DatabaseSync executes statements synchronously, schema bootstrapping is clean and happens right when the application boots up. If the table creation fails, the app exits immediately rather than failing silently later during a runtime request.

// Initialize tables
database.exec(`
  CREATE TABLE IF NOT EXISTS events (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    source TEXT NOT NULL,
    payload TEXT NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  );
`);
Enter fullscreen mode Exit fullscreen mode

In my view, running DDL statements synchronously during startup is a feature, not a bug. It ensures that your application state matches your schema expectations before any HTTP listener starts accepting incoming traffic.

Handling Queries in Express Routes

With the database initialized, writing Express route handlers is straightforward. We use .prepare() to compile our SQL statements, and then .run() or .all() to execute them. This protects against injection attacks out of the box through parameter binding.

Here is how I implemented a simple ingestion endpoint and a retrieval endpoint for my home automation server:

// Insert a new event
const insertStmt = database.prepare(
  'INSERT INTO events (source, payload) VALUES (?, ?)'
);

app.post('/api/events', (req, res) => {
  try {
    const { source, payload } = req.body;
    if (!source || !payload) {
      return res.status(400).json({ error: 'Missing source or payload' });
    }

    const result = insertStmt.run(source, JSON.stringify(payload));
    res.status(201).json({ id: result.lastInsertRowid, success: true });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// Retrieve recent events
const selectStmt = database.prepare(
  'SELECT * FROM events ORDER BY id DESC LIMIT ?'
);

app.get('/api/events', (req, res) => {
  try {
    const limit = parseInt(req.query.limit, 10) || 10;
    const rows = selectStmt.all(limit);
    res.json(rows);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Internal server error' });
  }});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});
Enter fullscreen mode Exit fullscreen mode

A Judgement on Performance and Simplicity

Using node:sqlite for internal tools and home automation tasks has been a smooth experience. My judgement is that while native modules like better-sqlite3 still offer advanced performance tuning and broader ecosystem familiarity for older Node versions, having a built-in SQLite driver changes the math for quick utilities.

You do not need to worry about python dependencies, node-gyp build failures on newer architectures, or managing external database servers like PostgreSQL for simple state management. For single-tenant micro-services running on a dedicated machine or home server, the native driver is more than fast enough, and the reduction in deployment friction is massive.

I build sites and automations for small businesses at dreamfuturestech.com.

Top comments (0)