When I look back at the handful of web apps I've shipped over the past few years, the most valuable artifact isn’t the code itself – it’s the collection of hard‑earned lessons that shape every new project. Below I break down the recurring themes that have saved me time, reduced bugs, and kept users happy, and I close with a concrete "if I were starting over" checklist.
Picking a Stack That Grows With You
The excitement of a shiny new framework can be intoxicating, but the long‑term cost of switching or fighting the framework’s quirks quickly outweighs the initial hype.
- Stick to mainstream, well‑documented libraries – they attract contributors, have mature tooling, and survive version upgrades longer.
- Separate concerns early – keep the UI, API, and data layers in distinct repositories or monorepo packages; this prevents accidental coupling.
- Prefer conventions over configuration – frameworks that enforce folder structures (e.g., Next.js, Remix) reduce boilerplate and make onboarding smoother.
- Benchmark early – run a simple load test on a prototype (see the
autocannonsnippet below) to ensure the stack can handle your expected traffic.
// Quick load test with autocannon (install globally with `npm i -g autocannon`)
const autocannon = require('autocannon')
autocannon({
url: 'https://my-app.example.com/api/health',
connections: 100,
duration: 30,
}).then(result => {
console.log('Requests per second:', result.requests.mean)
})
Architectural Decisions That Matter
Early architecture choices ripple through every subsequent sprint. Here are the patterns that have proven resilient:
- Stateless services – design APIs that don’t rely on server‑side session state; this simplifies scaling and enables zero‑downtime deployments.
- Domain‑driven folder layout – group files by business domain (e.g.,
orders/,users/) rather than by type (controllers/,services/). It mirrors the mental model of the problem space. - Feature toggles for risky releases – wrap new functionality in a flag that can be flipped without redeploying; this lets you ship half‑baked features safely.
- Use a thin API gateway – let a lightweight Express or Fastify layer handle authentication, routing, and request validation, delegating heavy lifting to micro‑services.
Example: A Minimal API Gateway Middleware
const express = require('express')
const jwt = require('jsonwebtoken')
const app = express()
// Simple auth middleware
app.use((req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).json({ error: 'Missing token' })
try {
req.user = jwt.verify(token, process.env.JWT_SECRET)
next()
} catch (e) {
res.status(403).json({ error: 'Invalid token' })
}
})
app.get('/api/orders', async (req, res) => {
// Proxy to orders micro‑service
const response = await fetch('http://orders.internal/api', {
headers: { 'X-User-Id': req.user.id }
})
const data = await response.json()
res.json(data)
})
app.listen(3000)
Testing and Quality Assurance
Skipping tests feels like a time‑saver until a production bug surfaces. The cost of a robust test suite is amortized across every release.
- Write unit tests for pure functions – they run fast and give immediate feedback on core logic.
- Add integration tests for API contracts – use a tool like Supertest to hit the real Express routes.
- Automate end‑to‑end (E2E) flows with Cypress – cover the most common user journeys to catch regressions in UI and backend together.
- Enforce coverage thresholds in CI – treat dropping below 80% as a build‑break.
Example: Jest Unit Test for a Pricing Helper
// pricing.js
export function calculateDiscount(price, percent) {
if (percent < 0 || percent > 100) throw new Error('Invalid percent')
return price - price * (percent / 100)
}
// pricing.test.js
import { calculateDiscount } from './pricing'
test('applies 20% discount correctly', () => {
expect(calculateDiscount(100, 20)).toBe(80)
})
test('throws on out‑of‑range percent', () => {
expect(() => calculateDiscount(100, -5)).toThrow('Invalid percent')
})
CI/CD and Automation
Manual deployments are the fastest path to human error. A repeatable pipeline eliminates that risk.
- Use GitHub Actions or GitLab CI for every push – lint, test, build, and deploy in a single workflow.
- Deploy to a preview environment on PR – let reviewers test the actual build, not just a screenshot.
- Version your Docker images with commit SHA – makes rollbacks deterministic.
- Seal secrets with a vault – never hard‑code API keys; inject them at runtime.
Example: Minimal GitHub Actions Workflow
name: CI
on: [push, pull_request]
jobs:
build-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test -- --coverage
- name: Build Docker image
run: |
docker build -t my-app:${{ github.sha }} .
- name: Push to registry (if on main)
if: github.ref == 'refs/heads/main'
run: |
echo ${{ secrets.REGISTRY_PASSWORD }} | docker login -u ${{ secrets.REGISTRY_USER }} --password-stdin
docker push my-app:${{ github.sha }}
Observability and Monitoring
You can’t improve what you can’t see. Early investment in logs, metrics, and alerts pays dividends when things go wrong.
- Structured JSON logging – makes it easy to query logs in Loki or CloudWatch.
- Export Prometheus metrics – expose request latency, error rates, and DB pool usage.
- Set up SLO‑based alerts – alert on 99th‑percentile latency crossing a threshold, not just on error count spikes.
- Add a health‑check endpoint – Kubernetes liveness and readiness probes rely on it.
// Example Prometheus metric with prom-client
const client = require('prom-client')
const requestDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.1, 0.5, 1, 2, 5]
})
app.use((req, res, next) => {
const end = requestDuration.startTimer({ method: req.method, route: req.path })
res.on('finish', () => {
end({ status_code: res.statusCode })
})
next()
})
Iterating on UX and Feedback Loops
Even the most technically perfect app fails if users can’t accomplish their goals.
- Ship a minimal MVP – validate assumptions before polishing UI.
- Instrument feature usage – send anonymous events to Mixpanel or PostHog to see what’s actually used.
- Run regular usability tests – a 5‑minute remote test uncovers friction that analytics miss.
- Close the feedback loop – turn every support ticket into a backlog item and tag it with the responsible component.
What I’d Do Differently Starting From Scratch
If I could hit the reset button, these are the concrete actions I’d take from day one:
- Adopt a monorepo with Nx – shared types and utilities stay in sync without publishing packages.
- Write a
CONTRIBUTING.mdthat includes linting, testing, and PR guidelines – reduces friction for external contributors. - Configure TypeScript strict mode from the start – catches many bugs before they compile.
- Add a Git pre‑commit hook (husky) to run lint and unit tests – prevents bad code from entering the repo.
- Set up feature flags with LaunchDarkly or an open‑source alternative – makes A/B testing painless.
- Invest in a real CDN for static assets – eliminates the need for later refactors to asset pipelines.
- Document the deployment process in a
docs/opsfolder – future you (or a new teammate) will thank you. - Allocate budget for a dedicated observability platform – avoid the "I wish we had logs" scramble after an outage.
Closing Thoughts
Building multiple web applications is a marathon, not a sprint. Each project refines your intuition about what truly matters: a stable architecture, automated quality gates, and a feedback‑driven product mindset. By codifying the lessons above and applying the "if I were starting over" checklist, you can shave weeks off future development cycles and ship with confidence.