<< Home

Chapter 16: Data and Persistence

Applications become useful once they store state safely and retrieve it quickly. This chapter explores relational databases, NoSQL systems, migrations, schema tools, caching layers, and message queues. Each section highlights patterns that work cleanly with TypeScript so your data access remains predictable and strongly typed.

💡 Treat persistence as part of application design rather than an afterthought. Simple models continue to scale when the boundaries are clear.

Relational databases and drivers

Relational engines such as PostgreSQL, MySQL, MariaDB, SQLite, and SQL Server provide structured storage with transactions and powerful query languages. Node offers several drivers and higher level abstractions that make these engines fit naturally into a TypeScript project.

Database Strengths Driver Notes
PostgreSQL Rich types, strong correctness, extensions pg Excellent JSON support and indexing
MySQL and MariaDB Broad hosting availability and speed mysql2 Fast prepared statements and pooling
SQLite Zero setup and file based better-sqlite3 Great for dev tools, tests, and small sites

Using pg with TypeScript

The pg client provides pooled connections and parameterised queries. Map rows to typed interfaces so handlers treat database output safely.

// db.ts
import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL
});

export async function getUser(id: number) {
  const r = await pool.query("select id, email, name from users where id = $1", [id]);
  return r.rows[0] as { id: number; email: string; name: string } | undefined;
}

Type safe query builders

Tools like Kysely and Drizzle infer types from table definitions. They help prevent mistakes by checking column names and result shapes at compile time. These tools are helpful when schemas evolve often.

// drizzle-example.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { users } from "./schema";
import { Pool } from "pg";

const db = drizzle(new Pool({ connectionString: process.env.DATABASE_URL }));

export async function allUsers() {
  return db.select().from(users);
}
⚠️ Avoid building SQL strings by concatenation. Use parameters to prevent injection and to let drivers optimise query plans.

NoSQL options and use cases

NoSQL databases store documents, key value pairs, graphs, or other flexible shapes. They work well when schemas change rapidly or when relationships are shallow. However, transactional guarantees and complex queries vary widely across systems.

System Model Strengths Typical uses
MongoDB Document Flexible shapes and indexing Content stores and user profiles
Redis Key value Speed and primitives like lists and sets Caches and rate limits
Cassandra Wide column Large scale writes Event logs and time series

MongoDB with the official driver

The official driver exposes collections as typed generics. This keeps inserts, updates, and queries consistent with your TypeScript interfaces.

// mongo.ts
import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGO_URL!);
const db = client.db("example");

export interface User {
  email: string;
  name: string;
  created: Date;
}

export async function createUser(u: User) {
  const coll = db.collection<User>("users");
  await coll.insertOne(u);
  return u;
}

Redis for ephemeral state

Redis excels when you need simple data structures that respond quickly. Use it for tokens, rate limits, counters, and caches.

// redis.ts
import { createClient } from "redis";

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

export async function bumpCounter(key: string) {
  return redis.incr(key);
}
💡 Avoid storing large documents or logs in Redis. Keep values small so memory remains steady and predictable.

Migrations and schema management

Schemas shift over time. Migrations bring structure to those changes so different environments stay aligned. Track migrations in version control and run them reliably during deployment.

Migration tools

Tools such as knex, drizzle-kit, and umzug provide structured migration files. Use sequential files or timestamped names to control ordering.

// knex-migration.js
export function up(knex) {
  return knex.schema.createTable("users", t => {
    t.increments("id");
    t.string("email").notNullable();
    t.string("name").notNullable();
    t.timestamp("created").defaultTo(knex.fn.now());
  });
}

export function down(knex) {
  return knex.schema.dropTable("users");
}

Safe rollouts

Prefer additive changes. Add new columns, migrate data gradually, then remove old columns once clients have switched. Heavy destructive changes slow deployments and risk downtime.

⚠️ Run migrations in a controlled environment. Avoid applying them from multiple nodes at once since this can create race conditions.

Versioning strategies

Use one of two approaches. You can keep a linear sequence of migration files or use timestamp based files that sort automatically. Both approaches work well as long as you test them in staging before production.

Caching layers and queues

Caching reduces repeated work and improves response times. Queues separate slow tasks from fast paths so users do not wait. Combined, these tools make applications smoother under load.

Layered caching

Combine in memory caches with distributed stores for speed and durability. In memory caches respond instantly while Redis or Memcached share state between processes. Use careful expiration policies so stale data remains rare.

// simple-cache.ts
const local = new Map<string, any>();

export function remember(key: string, ttlMs: number, compute: () => Promise<any>) {
  const item = local.get(key);
  if (item && item.expire > Date.now()) return item.value;
  return compute().then(value => {
    local.set(key, { value, expire: Date.now() + ttlMs });
    return value;
  });
}

Queues for background tasks

Use a queue to offload work that does not need to block an HTTP request. Jobs such as email, image resize, and report generation fit well here. Popular queue systems include RabbitMQ, Redis streams, and cloud managed queues.

// bullmq-example.ts
import { Queue } from "bullmq";
import IORedis from "ioredis";

const connection = new IORedis(process.env.REDIS_URL!);
const jobs = new Queue("emails", { connection });

export async function sendEmailJob(to: string, body: string) {
  await jobs.add("sendEmail", { to, body });
}

Workers and concurrency

Workers pull tasks from a queue and process them independently. Use concurrency settings that match your CPU and IO profile so each worker remains responsive without overwhelming the system.

// worker.ts
import { Worker } from "bullmq";
import IORedis from "ioredis";

const worker = new Worker("emails", async job => {
  const { to, body } = job.data;
  // send email …
}, {
  connection: new IORedis(process.env.REDIS_URL!),
  concurrency: 4
});
💡 Retain a clear boundary between HTTP handlers and background jobs. This keeps the online path light and lets workers scale independently.

By choosing appropriate storage engines, planning schema changes, and placing caches and queues carefully, your application becomes more reliable and more predictable as it grows.