An overengineered todo app
A single-user task system built and operated like production infrastructure — self-hosted Postgres, an edge worker, and a daemon that records every work session. This is what it costs, how fast it is, and how it holds up under attack.
Role
Design, build, operate
Scope
Domain modelling
API design
Edge runtime
Local daemon + storage
Stack
React, Vite, TypeScript, TanStack Query
Hono on Cloudflare Workers, Better Auth
Postgres via Hyperdrive, R2, launchd daemon
Everything a todo app does not need
Mogee tracks tasks. One user, one login, me. The domain is small on purpose. When the problem is already understood, the engineering is the only thing left to get right.
So the build treats it like a production system. That decision is the whole project, and this is what it produced.
Self-hosted Postgres
Running on hardware I own, patched and backed up by me.
Edge worker
Hono on Cloudflare Workers, serving the SPA and the API from one deployment.
Database-enforced invariants
Generated columns and triggers compute lifecycle state and time rollups. Clients never assert them.
One timer, guaranteed
A database constraint makes two running timers impossible on any device.
Screen recording daemon
launchd on a Mac drives OBS to record each work session.
Object storage pipeline
Multipart upload, 60-day retention, Range-request playback.
A full auth stack
Better Auth, MFA, session management. For one person.
Infrastructure
Five pieces. A Cloudflare Worker running Hono serves both the SPA and the API. Postgres runs on a mini PC at home. Hyperdrive sits between them and pools connections. R2 holds the session recordings. A launchd daemon on a Mac drives OBS and uploads what it captures.
Three of those choices were not obvious, so they are worth explaining.
One worker for the SPA and the API. Splitting them means two deployments, two sets of environment variables, and CORS between them. Serving both from one worker removes all three. The tradeoff is that a bad API deploy takes the frontend with it, which for a single-user app is an acceptable failure.
Self-hosted Postgres instead of managed. Managed Postgres for this workload costs between nine and fifteen dollars a month and buys failover I do not need. The box already exists and runs other things. The real cost of this decision is not money, it is that I own patching, backups and restores, and a disk failure is an outage as long as my restore takes.
Hyperdrive rather than a direct connection. Workers have no persistent process, so a naive setup opens a fresh Postgres connection on every request. At any real concurrency that exhausts the connection limit and the database stops answering. Hyperdrive holds the pool outside the worker. This is the single component most responsible for the app staying up under load, and section three has the numbers.
The database is not exposed to the public internet. Administration happens over Tailscale. The worker reaches it through the Hyperdrive binding, so a connection string never exists in client code.
Cost and scalability
Most cloud comparisons price a generic app. That does not help, because the bill depends on which line item your workload leans on. So the first step was measuring the shape of the load.
| Dimension | Measured | Effect on cost |
|---|---|---|
| Requests | ~2,400/day | Peak 40/min. Compute is not the cost centre. |
| Payloads | < 8 KB | Task rows and JSON. Negligible. |
| Database size | ~12k rows | Six tables under active read. Fits in memory. |
| Recordings written | ~3.2 GB/mo | 60-day retention, so about 6.4 GB at rest. |
| Recordings played back | ~35 GB/mo | Video egress is the bill. Everything else rounds to zero. |
That turns the provider question into a single question: what does each one charge to send bytes back out. Here is the same architecture priced on all four at current volume.
| Line item | Cloudflare | AWS | GCP | Azure |
|---|---|---|---|---|
| Compute | $5.00 | $0.86 | $0.00 | $0.00 |
| Managed Postgres | $0.00 | $14.71 | $9.37 | $12.41 |
| NAT / private networking | $0.00 | $32.40 | $0.00 | $0.00 |
| Object storage at rest | $0.10 | $0.15 | $0.16 | $0.13 |
| Egress on playback | $0.00 | $3.15 | $4.20 | $2.98 |
| Monthly total | $5.10 | $51.27 | $13.73 | $15.52 |
At this volume the gap is forty dollars a month and it does not matter. It matters at the next order of magnitude, because egress is the only line that scales linearly with use.
| Users | Playback egress/mo | R2 | S3 | GCS |
|---|---|---|---|---|
| 1 | 35 GB | $0 | $3 | $4 |
| 100 | 3.4 TB | $0 | $306 | $408 |
| 1,000 | 34 TB | $0 | $3,060 | $4,080 |
| 10,000 | 340 TB | $0 | $28,900 | $40,800 |
At a thousand users, egress alone is three thousand dollars a month on S3 and nothing on R2. That is the reason the recordings live where they live. It is not a preference for Cloudflare. This workload's dominant cost happens to be the one thing R2 does not charge for, and a workload centred on compute or storage volume would have gone somewhere else.
That projection assumes the rest of the architecture reaches a thousand users. It does not. Three things break first.
| What breaks | At roughly | Why |
|---|---|---|
| Single Postgres box | ~400 concurrent | One machine, no replica, no failover. A disk failure is an outage as long as the restore takes. |
| Recorder daemon model | 2 users | It assumes one Mac running launchd. There is no multi-machine design. This is the least scalable part of the system. |
| Single-tenant schema | 2 users | No tenant column anywhere. Adding one is a migration across every table and every query in the table API. |
What I chose not to buy: read replicas, multi-region, managed database, autoscaling, a standby. Each one costs real money against a failure mode whose worst case is a morning without a task list. The restore path is a manual procedure and it is written down. For this blast radius that is the right amount of engineering.
Performance
One user generates no load worth measuring. So the approach was to measure what exists, then generate load until it broke. The useful number is not the latency. It is the concurrency at which the latency stops being true.
Baseline first, measured from a warm edge location, thirty runs per endpoint, against production data.
| Endpoint | p50 | p95 | p99 |
|---|---|---|---|
| Task list, 200 rows | 11 ms | 29 ms | 58 ms |
| Categories | 8 ms | 19 ms | 34 ms |
| Start timer (write) | 17 ms | 41 ms | 77 ms |
| Category burn-up chart | 14 ms | 33 ms | 62 ms |
| Recording seek, 1 MB range | 22 ms | 48 ms | 96 ms |
The burn-up row used to be the worst thing in the app. That chart needs total time spent per category, which is a sum across every session ever recorded against every task in it. Computed on read, that aggregate grows forever.
So it is not computed on read. A trigger maintains actual_seconds on the task row as sessions close. The work moves to write time, where it is a single row update nobody is waiting on.
| Approach | 5k sessions | 50k sessions | Write cost |
|---|---|---|---|
| Aggregate on read | 840 ms | 7,900 ms | — |
| Trigger-maintained rollup | 14 ms | 14 ms | +0.4 ms |
The risk in that trade is real: if the trigger is ever wrong, the number is silently wrong forever. So there is a reconciliation query that recomputes the rollup from scratch and compares it against the stored value.
Then the load test. k6, three-minute ramps, against the read-heavy path a real session generates.
| Concurrent sessions | p99 | Errors | What is happening |
|---|---|---|---|
| 100 | 78 ms | 0.00% | Flat. |
| 250 | 141 ms | 0.00% | Still flat. |
| 400 | 412 ms | 0.18% | Connection pool begins queueing. |
| 550 | 2,310 ms | 4.10% | The knee. Pool saturated, requests queue behind it. |
| 700 | timeout | 22.4% | Workers hit the wall-clock limit waiting for a connection. |
Finding the knee mattered less than finding the cause. The app was issuing four queries per page render where one would do. Collapsing them moved the ceiling without changing any infrastructure.
550 → 900
Concurrent sessions before the knee
2,310 → 190 ms
p99 at 550 concurrent
4 → 1
Queries per page render
Nine hundred concurrent sessions is not impressive in absolute terms. It is roughly two orders of magnitude more headroom than this app will ever use, it cost an afternoon, and the next wall is known: the single Postgres instance, which has no replica to read from.
Security
This app records video of my screen while I work. That footage contains client work, private messages, and credentials that were not meant to be on screen. Everything else in the system is a task list. The threat model is almost entirely about the video.
Scope first, because a threat model that defends against everything defends against nothing. In scope: an opportunistic scanner finding the public endpoint, a stolen laptop running the daemon, a compromised dependency in the build, a leaked session cookie. Out of scope: tenant isolation, because there is one tenant; insider threat, because the insider is me; a targeted state-level adversary.
| Asset | Worst case | Control | Residual risk |
|---|---|---|---|
| Session recordings | Disclosure of everything on screen for 60 days | Private bucket, no public read. Playback proxied through a session-gated worker issuing 5-minute signed ranges. 60-day hard expiry. | Any live session can view all recordings. Accepted: that is the account owner. |
| Daemon API token | Attacker writes junk recordings or reads existing ones | Write-scoped to two recording endpoints. Cannot read task data, cannot list the bucket. 30-day rotation. | A stolen laptop can upload garbage until rotation. Detectable, not preventable. |
| Postgres credentials | Total data loss | Never present in client code. Hyperdrive binding rather than a connection string. Database not exposed to the internet, administered over Tailscale. | Low. |
| The single login | Account takeover, which means all of the above | Argon2id, MFA required, 7-day sessions, cookies secure and httpOnly with sameSite strict. | Phishing. MFA is the only control standing here. |
| Task and note content | Disclosure | Session-gated table API with an allowlist. | Low. |
The table API needs its own explanation. One handler serves every table through /api/t/:table, which is convenient and is also the kind of endpoint that ends up in someone else's writeup with a CVE attached. It works because of what it refuses.
The table name must match an allowlist of six. Every column named in a filter or sort must appear in that table's own allowlisted column set. Every value is a bound parameter and nothing is interpolated into SQL. Anything outside those rules returns a 400 before reaching the query builder. The auth, session and account tables are not on the list and no input reaches them through this path.
And the controls I decided not to build:
| Not built | Reason |
|---|---|
| A WAF | It buys signature matching against injection and traversal classes the allowlist already forecloses. A second, weaker copy of a control already in place. |
| Rate limiting beyond the platform default | One user cannot generate abusive load. This becomes wrong the moment signup is public, and it is the first thing that changes. |
| Automated secret rotation | Four secrets, rotated by hand, procedure written down. Automation here is more moving parts than the thing it protects. |
| An audit log | No good reason. This one is a real gap. There is no record of who viewed which recording and when, and the recordings are the sensitive asset. It is the next thing being built. |
See the next project ↓
Records
Companies
71| Company | Stage | Industry | People | Notes | Added | |
|---|---|---|---|---|---|---|
| | Reply received | AI / Foundation Models | SA GB | ChatGPT, GPT-4, Codex | May 9 | |
| | Intro booked | AI / Foundation Models | DA | Claude, Constitutional AI | May 9 | |
| | Message sent | Fintech / Payments | PC JD | Online payments infra | Apr 21 | |
| | Interviewing | Dev tools / Infra | GR | Frontend cloud, Next.js | Apr 10 | |
| | Reply received | Dev tools / PM | KH | Issue tracking for SaaS | Apr 21 | |
| | Person identified | Design / Collab | DF | Collaborative design tool | May 9 | |
| | Message sent | Productivity | IZ | Docs, wikis, projects | Apr 10 | |
| | Message sent | Marketplace | BC | Marketplace for stays | Apr 21 | |
| | Person identified | E-commerce | TL | E-commerce platform | May 9 | |
| | Reply received | Fintech / Cards | EG KM | Corporate cards & spend | Apr 10 | |
| | Message sent | Comms / Social | JV | Voice, video, chat | Apr 21 | |
| | Reply received | Consumer / Audio | DE | Music streaming | Apr 10 | |
| | Message sent | Crypto / Fintech | BA | Crypto exchange | Apr 21 | |
| | Person identified | Fintech / Infra | ZP | Banking API infrastructure | May 9 |
Atlas
A career CRM that runs the job search like a sales pipeline.
Onefold
The inference layer under every app I run — routing, translation, metering.