To-do app backend

A small service that remembers each person's tasks, and only ever shows them their own.

The idea

A to-do backend is a small CRUD service: the client sends HTTP requests, the API validates them, and rows land in a tasks table. Create, read, update, delete — that is the whole surface.

Two things separate a real one from a toy. First, ownership: every request carries a logged-in user, and the API only ever touches rows where user_id matches that user. Second, pagination: a list endpoint returns a bounded page, not the whole table, so a busy account never ships ten thousand rows in one response.

See it work

Pick an operation, then press Play or Step.

How it works

The API maps HTTP verbs to rows. Each handler first reads the authenticated user, then scopes every query by user_id. The list endpoint is paginated and ordered so pages are stable.

# --- schema ---------------------------------------------------
CREATE TABLE tasks (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT      NOT NULL REFERENCES users(id),
  title       TEXT        NOT NULL,
  done        BOOLEAN     NOT NULL DEFAULT false,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX tasks_by_user ON tasks (user_id, id);

# --- a couple of REST endpoints (pseudo-Python) ---------------
@post("/tasks")
def create_task(req, user):
    title = req.json["title"].strip()
    if not title or len(title) > 200:        # validate input
        return error(422, "title required, max 200 chars")
    row = db.insert(
        "INSERT INTO tasks (user_id, title) VALUES (?, ?) RETURNING *",
        user.id, title)
    return json(201, row)

@get("/tasks")
def list_tasks(req, user):
    limit  = min(int(req.query.get("limit", 20)), 100)
    cursor = req.query.get("cursor")          # keyset, not offset
    rows = db.query(
        "SELECT * FROM tasks "
        "WHERE user_id = ? AND (? IS NULL OR id < ?) "  # own rows only
        "ORDER BY id DESC LIMIT ?",
        user.id, cursor, cursor, limit + 1)
    next_cursor = rows[limit].id if len(rows) > limit else None
    return json(200, {"items": rows[:limit], "next_cursor": next_cursor})

Toggle and delete follow the same shape — they update or remove WHERE id = ? AND user_id = ?, so a request can never touch another person's task even if it guesses the id.

Trade-offs

ChoiceLighter optionWhen it pays off
Storage engine SQL (relational, joins, transactions) A key-value store scales writes simply, but you lose ad-hoc queries and ordering; for a to-do app SQL is the easy default.
Pagination Offset (LIMIT/OFFSET) Keyset (cursor) stays correct and fast as rows are inserted; offset is simpler to write but drifts and slows on deep pages.
Delete Hard delete (row gone) Soft delete (deleted_at) keeps an audit trail and supports undo, at the cost of every query filtering it out.
Done state Boolean done A completed_at timestamp answers "when" and enables stats, but a boolean is enough to ship.

Watch out for

Worked example

Maria opens the app. The client sends GET /tasks?limit=20 with her session token. The API resolves the token to user_id = 7, runs SELECT … WHERE user_id = 7 ORDER BY id DESC LIMIT 21, and returns the first 20 rows plus a next_cursor. She types "Buy milk" and hits add: POST /tasks with {"title":"Buy milk"}. The handler trims and length-checks the title, inserts (user_id=7, title='Buy milk', done=false), and replies 201 with the new row — which the client prepends to the list. She checks it off: PATCH /tasks/91 {"done":true} runs UPDATE tasks SET done=true WHERE id=91 AND user_id=7. One row changed. If Maria's id had been anything but 7, that update would have matched zero rows and returned 404 — the ownership clause does the security work silently.

Check yourself

1. A list endpoint runs SELECT * FROM tasks LIMIT 20 OFFSET 40 with no user_id filter and no ORDER BY. What is the most serious problem?

2. Between two page loads, a user adds three tasks. Which pagination style keeps the second page correct without duplicates or skips?