hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Real-Time APIs with @hectoday/http

Beyond Request-Response

  • Why Real-Time Matters
  • Project Setup

Polling

  • Short Polling
  • Long Polling

Server-Sent Events

  • How SSE Works
  • Building an SSE Endpoint
  • Event Types and IDs
  • SSE in Practice

WebSockets

  • How WebSockets Work
  • Building a WebSocket Server
  • Rooms and Broadcasting
  • Authentication on WebSockets
  • Handling Disconnects and Reconnection

Patterns and Architecture

  • Pub/Sub
  • Presence
  • Scaling Real-Time

Putting It All Together

  • Choosing the Right Approach
  • Capstone: Live Task Board

Project Setup

The domain

A task board like Trello or Linear, simplified: lists contain tasks, users collaborate on the same board, and changes by one user should appear instantly for everyone else.

This domain naturally requires real-time: when Alice moves a task to “Done,” Bob should see it move without refreshing.

Create the project

mkdir taskboard-api
cd taskboard-api
npm init -y
npm install @hectoday/http zod srvx better-sqlite3
npm install -D typescript @types/node @types/better-sqlite3 tsx

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "rootDir": "./src",
    "outDir": "dist",
    "types": ["node"]
  },
  "include": ["src"]
}

Add "type": "module" and a dev script to package.json:

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts"
  }
}

To start the server:

npm run dev

The database schema

// src/db.ts
import Database from "better-sqlite3";

const db = new Database("taskboard.db");
db.pragma("journal_mode = WAL");

db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    email TEXT UNIQUE NOT NULL
  );

  CREATE TABLE IF NOT EXISTS boards (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now'))
  );

  CREATE TABLE IF NOT EXISTS lists (
    id TEXT PRIMARY KEY,
    board_id TEXT NOT NULL,
    name TEXT NOT NULL,
    position INTEGER NOT NULL DEFAULT 0,
    FOREIGN KEY (board_id) REFERENCES boards(id)
  );

  CREATE TABLE IF NOT EXISTS tasks (
    id TEXT PRIMARY KEY,
    list_id TEXT NOT NULL,
    title TEXT NOT NULL,
    description TEXT,
    assignee_id TEXT,
    position INTEGER NOT NULL DEFAULT 0,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT NOT NULL DEFAULT (datetime('now')),
    FOREIGN KEY (list_id) REFERENCES lists(id),
    FOREIGN KEY (assignee_id) REFERENCES users(id)
  );
`);

export default db;

Four tables: users, boards, lists, and tasks. A board has lists (“To Do,” “In Progress,” “Done”), and lists have tasks. This is enough to demonstrate every real-time pattern.

Seed data

const existing = db.prepare("SELECT id FROM users LIMIT 1").get();
if (!existing) {
  // Users
  db.prepare("INSERT INTO users (id, name, email) VALUES (?, ?, ?)").run(
    "user-alice",
    "Alice",
    "[email protected]",
  );
  db.prepare("INSERT INTO users (id, name, email) VALUES (?, ?, ?)").run(
    "user-bob",
    "Bob",
    "[email protected]",
  );

  // Board
  db.prepare("INSERT INTO boards (id, name) VALUES (?, ?)").run("board-1", "Sprint 12");

  // Lists
  db.prepare("INSERT INTO lists (id, board_id, name, position) VALUES (?, ?, ?, ?)").run(
    "list-todo",
    "board-1",
    "To Do",
    0,
  );
  db.prepare("INSERT INTO lists (id, board_id, name, position) VALUES (?, ?, ?, ?)").run(
    "list-progress",
    "board-1",
    "In Progress",
    1,
  );
  db.prepare("INSERT INTO lists (id, board_id, name, position) VALUES (?, ?, ?, ?)").run(
    "list-done",
    "board-1",
    "Done",
    2,
  );

  // Tasks
  db.prepare(
    "INSERT INTO tasks (id, list_id, title, assignee_id, position) VALUES (?, ?, ?, ?, ?)",
  ).run("task-1", "list-todo", "Design login page", "user-alice", 0);
  db.prepare(
    "INSERT INTO tasks (id, list_id, title, assignee_id, position) VALUES (?, ?, ?, ?, ?)",
  ).run("task-2", "list-todo", "Set up CI pipeline", "user-bob", 1);
  db.prepare(
    "INSERT INTO tasks (id, list_id, title, assignee_id, position) VALUES (?, ?, ?, ?, ?)",
  ).run("task-3", "list-progress", "Build API endpoints", "user-alice", 0);
  db.prepare(
    "INSERT INTO tasks (id, list_id, title, assignee_id, position) VALUES (?, ?, ?, ?, ?)",
  ).run("task-4", "list-done", "Write project brief", "user-bob", 0);
}

REST endpoints

Basic CRUD for the task board (from the REST course patterns):

// src/routes/tasks.ts
import * as z from "zod/v4";
import { route, group } from "@hectoday/http";
import db from "../db.js";

const CreateTaskBody = z.object({
  title: z.string().min(1).max(500),
  description: z.string().optional(),
  listId: z.string().min(1),
  assigneeId: z.string().optional(),
});

const MoveTaskBody = z.object({
  listId: z.string().min(1),
  position: z.number().int().min(0),
});

export const taskRoutes = group([
  route.get("/boards/:boardId/tasks", {
    resolve: (c) => {
      const tasks = db
        .prepare(
          "SELECT t.* FROM tasks t JOIN lists l ON t.list_id = l.id WHERE l.board_id = ? ORDER BY l.position, t.position",
        )
        .all(c.params.boardId);
      return Response.json({ data: tasks });
    },
  }),

  route.post("/tasks", {
    request: { body: CreateTaskBody },
    resolve: (c) => {
      if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
      const { title, description, listId, assigneeId } = c.input.body;
      const id = crypto.randomUUID();
      db.prepare(
        "INSERT INTO tasks (id, list_id, title, description, assignee_id, position) VALUES (?, ?, ?, ?, ?, ?)",
      ).run(id, listId, title, description ?? null, assigneeId ?? null, 0);
      const task = db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
      return Response.json(task, { status: 201 });
    },
  }),

  route.patch("/tasks/:id/move", {
    request: { body: MoveTaskBody },
    resolve: (c) => {
      if (!c.input.ok) return Response.json({ error: c.input.issues }, { status: 400 });
      const { listId, position } = c.input.body;
      const result = db
        .prepare(
          "UPDATE tasks SET list_id = ?, position = ?, updated_at = datetime('now') WHERE id = ?",
        )
        .run(listId, position, c.params.id);
      if (result.changes === 0) return Response.json({ error: "Task not found" }, { status: 404 });
      const task = db.prepare("SELECT * FROM tasks WHERE id = ?").get(c.params.id);
      return Response.json(task);
    },
  }),

  route.delete("/tasks/:id", {
    resolve: (c) => {
      const result = db.prepare("DELETE FROM tasks WHERE id = ?").run(c.params.id);
      if (result.changes === 0) return Response.json({ error: "Task not found" }, { status: 404 });
      return new Response(null, { status: 204 });
    },
  }),
]);
// src/app.ts
import { setup, route } from "@hectoday/http";
import { taskRoutes } from "./routes/tasks.js";

export const app = setup({
  routes: [route.get("/health", { resolve: () => Response.json({ status: "ok" }) }), ...taskRoutes],
});
// src/server.ts
import { serve } from "srvx";
import { app } from "./app.js";
serve({ fetch: app.fetch, port: 3000 });

These endpoints are standard REST. In the next lessons, we add real-time: when Alice creates a task or moves one, Bob sees it instantly.

Exercises

Exercise 1: Start the server and list tasks: curl http://localhost:3000/boards/board-1/tasks.

Exercise 2: Create a task and move it to a different list. Verify the update.

Exercise 3: Think about which operations should trigger real-time updates: task created, task moved, task deleted, task updated. (Answer: all of them.)

Why is a task board a good domain for learning real-time APIs?

← Why Real-Time Matters Short Polling →

© 2026 hectoday. All rights reserved.