Back to articles
engineering July 30, 2026 6 min read

Lessons Learned from Building Multiple Web Applications

After shipping several web apps, I distilled the hard‑earned lessons on stack choice, architecture, testing, CI/CD, and UX—plus a concrete checklist for starting over.

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.

// 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:

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.

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.

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.

// 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.

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:

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.

Need something like this built?

I work on full-stack web apps — backend systems, APIs, and the front-ends that sit on top. If this post was useful and you've got a project that needs it, I'd like to hear about it.

Want future posts like this?

No mailing list yet — for now, email me and I'll let you know when something new goes up.

Email me