← All projects

irl

A serverless URL shortener designed for minimal latency: ~350 lines of production code, ~2× as much test code.

  • ~350lines of production code
  • ~2×lines of tests
  • 31tests (unit + E2E)
  • 1Lambda, no API Gateway
Role
Solo author
Stack
TypeScript · Nuxt / Nitro · AWS Lambda · DynamoDB · AWS CDK · Playwright
Links
GitHub

Context

irl is a serverless URL shortener with a simple thesis: every architecture decision should shorten the path between the click and the redirect. The project was also a lab for a question I care about more than the product itself: how do you test serverless honestly — without a paid account, and without lying to yourself?

The problem

Three problems intersect in a well-built serverless shortener:

  • latency: every extra hop (API Gateway, a second service, an unnecessary cold start) shows up in redirect response time;
  • short-ID consistency: with a 6-character nanoid, collisions are rare but real — and in serverless there is no shared memory to coordinate;
  • testability: DynamoDB and Lambda are hard to run locally, so it’s tempting to test only the happy path and leave E2E “for later” — which is the same as not testing.

How it was built

  • A single Lambda function serves the whole app: Nitro bundles SSR and the API routes into one artifact, exposed through a Lambda Function URL — no API Gateway in between.
  • On-demand DynamoDB with a single table (id, url, createdAt). Creation uses a ConditionExpression to guarantee uniqueness without coordination: if the ID already exists, the write fails and the service retries with another one.
  • Layered server with dependency injection: handler → service → repository. The repository is an interface; swapping the AWS client for the local database is swapping one parameter.
  • Infrastructure as code with CDK v2: function, URL, CORS and least-privilege IAM, all defined in the same repository.
  • Reproducible environment with Nix, including the Playwright browsers — any machine reproduces the whole setup.
Diagram: browser → Function URL → Lambda Nitro → DynamoDB

A single Lambda serves SSR and the API behind the Function URL — no API Gateway in the critical path.

Decisions and trade-offs

  • Function URL instead of API Gateway. One less component in the critical path and no gateway cost; in exchange, some ready-made capabilities (authentication, WAF, rate limiting) stay out — acceptable for the scope, and documented.
  • IDs generated without coordination. nanoid(6) + conditional write + bounded retry handles collisions without distributed locking: less infrastructure, more application code — a trade I would make again.
  • Stateful fakes instead of mocks. Service tests run against a stateful fake repository that behaves like the database; repository tests run against a fake DynamoDB client with condition semantics. A mock that only “expects the call” would not have found the bugs that appeared.
  • E2E with a local browser and database. Playwright boots the app against DynamoDB Local — same port, same protocol as the AWS service — instead of simulated responses.

Quality and verification

The project has about twice as much test code as production code (~675 lines against ~350), and the tests cover error paths, not just the happy path: URL validation (including dangerous schemes), ID collision, malformed items, 400/404/500 responses. The process is disciplined: conventional commits, a pre-commit hook running the suite, automated versioning and changelog.

The E2E tests caught two bugs no unit test would have:

  1. The click before hydration. Playwright clicks as soon as the button is actionable — which on slow connections can happen before Vue hydrates the page. The click becomes a native browser form submit, and the input is lost. The fix is waiting for the framework’s hydration signal before interacting (waitForFunction), and the app still has an honest pending item: disabling the form until it mounts.
  2. The ghost server. The E2E setup was configured to reuse whatever server was already running on the test port. Result: some runs tested an old build and went green on code that was not the current code. The fix was the strictest one: an occupied port is now an error, never a convenience.

These two cases stand as proof: without a browser and a database, the test lies to you.

Results

The full flow works: shorten with validation, redirect, explicit 404 for unknown IDs — with release 0.2.0 versioned and a changelog generated from commits. What I would finish before calling it complete: server-side redirect (today the redirect resolves on the client, costing extra round-trips — contradicting the project’s own latency thesis), CI running the E2E on every push, and a page with measured latency numbers. The pending items are in the README, in plain sight.

What I learned

Well-designed serverless infrastructure is the kind whose local testing cost is zero: with DynamoDB Local and one bootstrap command, the E2E runs on any machine. And an E2E test that reuses environment state is not E2E — it is an optimistic unit test. Better to fail loudly.