← Back to journal

Building a real-time chat app with one database.

Matty Hogan

Matty Hogan · March 29, 2026

Try it live: the full demo is running at luxdb.dev/demo on a single Lux instance.

Most chat apps need at least three services: a relational database for users and messages, Redis for caching and pub/sub, and some kind of WebSocket server for real-time delivery. Maybe a fourth for rate limiting. A fifth for presence tracking.

I built one with a single database connection. Here's how.

The architecture

Lux is a Redis-compatible database that also has built-in tables, pub/sub, and key-value storage. So instead of splitting data across Postgres + Redis + a pub/sub broker, everything runs through one Lux connection:

typescript
import { Lux } from "@luxdb/sdk";
const lux = new Lux("lux://localhost:6379");

Here's what each chat feature maps to:

FeatureTraditional StackLux
Users & messagesPostgresTCREATE, TINSERT, TQUERY
Real-time deliveryRedis pub/sub + WSPUBLISH (built-in)
PresenceRedis SET + TTLSET with EX (same API)
Rate limitingRedis INCR + EXPIREINCR + EXPIRE (same API)
Typing indicatorsRedis pub/subPUBLISH

A single connection string replacing what would normally be three separate services.

Schema

Lux has built-in relational tables with typed fields, foreign keys, and queries. The entire schema for the chat app is three commands:

typescript
await lux.call("TCREATE", "users",
  "username:str:unique", "color:str", "last_seen:int");

await lux.call("TCREATE", "channels",
  "name:str:unique", "topic:str");

await lux.call("TCREATE", "messages",
  "channel_id:ref(channels)", "user_id:ref(users)",
  "content:str", "timestamp:int");

ref(users) is a foreign key. Lux enforces referential integrity without needing an ORM, migrations, or schema files.

Sending a message

When a user sends a message through the WebSocket, the server does four things in sequence:

typescript
// 1. Rate limit (5 messages per 10 seconds)
const count = await lux.incr(`ratelimit:msg:${userId}`);
if (count === 1) await lux.expire(rateKey, 10);
if (count > 5) return ws.send("slow down");

// 2. Filter profanity
content = filter.clean(content);

// 3. Store in table
const id = await lux.call("TINSERT", "messages",
  "channel_id", channelId,
  "user_id", userId,
  "content", content,
  "timestamp", String(Date.now()));

// 4. Broadcast to channel
broadcast(channelId, { type: "message", id, content, ... });

Rate limiting, storage, and broadcast all happen through the same connection. No inter-service latency, no serialization overhead, no distributed transaction coordination.

Presence

Online/offline presence uses key-value TTLs. When a user connects or sends a heartbeat:

typescript
await lux.call("SET", `presence:${userId}`, "online", "EX", "60");

The key automatically expires after 60 seconds. To check who's online, read the presence keys for each user. If the key exists, they're online. If it expired, they're offline. No cleanup jobs, no zombie connections.

Loading messages

Message history uses table queries with joins resolved via pipeline:

typescript
// Get last 50 messages
const rows = await lux.call("TQUERY", "messages",
  "WHERE", "channel_id", "=", channelId,
  "ORDER", "BY", "timestamp", "DESC",
  "LIMIT", "50");

// Batch-fetch usernames
const pipeline = lux.pipeline();
for (const uid of uniqueUserIds) {
  pipeline.call("TGET", "users", uid);
}
const users = await pipeline.exec();

The pipeline batches all user lookups into a single round trip. 50 messages with usernames resolved in two round trips total.

What's NOT in the server

Things I didn't have to set up:

  • No Postgres, no connection pooling, no migrations, no pgAdmin
  • No separate Redis for caching or pub/sub
  • No message queue service, rate limiting is just INCR + EXPIRE
  • No presence service, TTL keys handle it
  • No Docker Compose with four containers, just one binary

The entire database is a single Lux instance running on a $10/mo Lux Cloud project.

How it runs

The demo at luxdb.dev/demo runs on a single Lux Cloud instance with 512MB of RAM ($10/mo). Messages are persisted via tiered storage, which automatically evicts cold data to disk when memory fills up. Rate limiting and presence are handled with standard key-value operations that add no meaningful overhead.

Try it

The full demo is running live at luxdb.dev/demo.

If you want to build something similar:

typescript
docker run -p 6379:6379 ghcr.io/lux-db/lux:latest

# or use Lux Cloud
# luxdb.dev

Connect with any Redis client. The tables, vectors, time series, and pub/sub commands are all available alongside the standard Redis commands you already know.

That's the whole thing: a real-time chat app backed by a single database with a single connection string.