Skip to content
Yusuf Özdemir
ALL ARTICLES

An overview of web service tooling: what belongs in each layer

13 MIN READ 2,412 WORDS
ALSO IN Türkçe

There's no right answer to "Hono or Nitro or Nginx," because those three don't do the same job. Hono is an application framework, Nitro is a server compiler, Nginx is a proxy that sits in front of whatever you built. A single request can pass through all three.

Separating the layers first is what makes the comparison useful. Skip that step and the discussion collapses into "which one is faster," which is the least important question of the set.

I've written this around how we actually work: the services carry most of the logic, and we expose them behind as thin a UI as we can get away with. No heavy SPA, mostly an API with a handful of screens on top. The recommendations below assume that shape. If you're building a heavily interactive product interface instead, the UI section in particular will land differently for you.

The layers a request passes through

client
   [ 1. entry layer ]      Nginx / Caddy / Traefik / gateway
       [ 2. runtime ]       Node / Bun / Deno / Workers
           [ 3. app ]       Hono / Fastify / Elysia / Nitro / Express

Requests flow top to bottom, but decisions don't get made in that order. In practice you need to know where this is going to be deployed first, because the deployment target constrains the runtime, and the runtime constrains which frameworks are even on the table. If you're going to Cloudflare Workers, Fastify was never an option. If you're on a single VPS, the entire edge conversation doesn't apply to you.

The application layer

Hono

Built on web standards, meaning it works with Request and Response objects. The practical consequence is that the same code runs on Node, Bun, Deno, Cloudflare Workers and Lambda. It's around 14 KB against Express's 200-plus, and on edge runtimes that size shows up directly in cold start time.

import { Hono } from 'hono'

const app = new Hono()

app.get('/health', (c) => c.json({ ok: true }))

app.post('/invoices', async (c) => {
  const body = await c.req.json()
  const invoice = await createInvoice(body)
  return c.json(invoice, 201)
})

export default app

That file deploys unchanged to Workers or to Node.

You can learn it in an afternoon and the TypeScript side is clean. The "runs anywhere" promise, though, is something your own code breaks. Reach for fs, Buffer, a native library, or any long-running operation and you'll find that particular handler doesn't run on Workers after all. Portability is a property of the framework, not of your application. The middleware ecosystem that grew around Express over the years also isn't here, so expect to write some of it yourself.

It fits small to mid-sized services, APIs headed for the edge, and anything where the deployment target might move later.

Fastify

If you're staying on Node, this is my default. It earns that through schemas rather than throughput: JSON Schema handles validation and serialisation, and the same schema generates your OpenAPI document.

import Fastify from 'fastify'

const app = Fastify({ logger: true })

app.post('/invoices', {
  schema: {
    body: {
      type: 'object',
      required: ['customerId', 'amount'],
      properties: {
        customerId: { type: 'string' },
        amount: { type: 'number', minimum: 0 },
      },
    },
    response: {
      201: {
        type: 'object',
        properties: { id: { type: 'string' }, amount: { type: 'number' } },
      },
    },
  },
}, async (req, reply) => {
  const invoice = await createInvoice(req.body)
  return reply.code(201).send(invoice)
})

That response schema does more than document the endpoint. Fastify compiles a fast serialiser from it, and fields missing from the schema stay out of the response entirely, which makes leaking a password_hash by accident considerably harder. In an architecture where services call each other, that property is worth more than the benchmark difference.

The plugin system is mature and gives you real encapsulation, and the whole Node ecosystem stays available. The cost is that you're tied to Node, with no edge runtimes on the table. Writing schemas also slows you down early on; you get it back as the number of services grows.

Elysia

Written for Bun and built to exploit its characteristics. The type inference is among the best here: it derives types from your schema definitions, so you're not writing them twice, and it's quick with it.

In return you're committing to Bun, which isn't automatically bad, but it welds the runtime decision to the framework decision and backing out means changing both at once. Sensible if you've already adopted Bun, not worth switching for on its own.

Express

35 million weekly downloads and a codebase in maintenance mode. There's no urgency to migrate existing Express services; if they work, leave them. But there's also no remaining reason to start a new one with it, since all three options above give you more.

Nitro

Not the same category as the rest of this list. Nitro is closer to a server compiler than a framework: it takes your server code and packages it for a target. There are 15-plus presets, so the same project builds for Vercel, Cloudflare, Lambda, or a plain Node server (node_server). It's what runs underneath Nuxt, and it works fine without Nuxt.

It pays off by making a later change of deployment target genuinely feasible. The standard preset produces a server bundle under 10 KB, and compatibility dates pin behaviour to when the project was created, so you upgrade deliberately rather than by surprise — worth something for services that live for years.

The cost is one more layer in the toolchain. When something goes wrong, the error sits somewhere between your code and generated output, and reading that is more work than reading a Fastify stack trace. Worth paying if you want a thin UI and an API in one project deployed to one place, or if you haven't decided where it's going yet.

Serving a thin UI

In our case the interface is the visible surface of a service rather than the product itself. Stating that plainly resolves a lot of downstream decisions.

Three common approaches.

Static files plus a separate API. Build the interface, serve it from a CDN or the proxy, keep the API on its own address. It looks like the simplest setup and you pay for it in sessions and CORS. The moment there are two origins you're dealing with cookie attributes, preflight requests and where to keep tokens. For a handful of screens that's a bad trade.

Server-side rendering in the same process. Nitro, Nuxt, Next or similar, with the interface and API in one place. A single origin means the session problem disappears on its own. The cost is a heavier build pipeline.

HTML straight from the server. If the UI really is thin, this has the fewest moving parts. Blade or Inertia on the Laravel side, or server-rendered HTML with a small amount of interaction layered on. No separate frontend build, no second deploy step, no second dependency tree.

The threshold I use: if you can count the screens on one hand and most of them are forms and tables, splitting out a separate frontend service costs more than it returns. A separate frontend means a second deployment pipeline, a second set of dependencies to keep current, and the possibility of the API and the UI drifting out of sync.

Direction matters when you pick here. If the interface genuinely outgrows the arrangement later, moving from combined to a separate frontend is straightforward, because the API is already sitting there. Going the other way, starting with two services and collapsing them into one, is considerably more work. So when you're unsure, combined is the cheaper bet.

The entry layer

Nginx

Twenty years old and serving more than a third of the internet. It's the fastest option for static files and reverse proxying, its behaviour is predictable, and whatever problem you hit has been written up somewhere.

Against that, the configuration is hand-written and grows with every service you add. TLS certificates are a separate job needing certbot or something like it. If services come and go dynamically, Nginx won't notice on its own; you touch the config every time. Where the topology is stable, static traffic is heavy, or someone on the team already knows it well, it's still the most solid option.

Caddy

Automatic HTTPS as default behaviour: you name the domain and certificates handle themselves. The config stays readable:

api.example.com {
    reverse_proxy localhost:3000
}

example.com {
    root * /var/www/ui
    file_server
    try_files {path} /index.html
}

The equivalent Nginx block is several times longer and includes certificate paths you have to keep correct.

Certificate management stops being something you own, and you're running a single binary. There's less room for tuning than Nginx under serious traffic and the module ecosystem is smaller. For one server or a few services — the kind of setup described at the top — this is usually the right default.

Traefik

Discovers services from Docker labels and Kubernetes resources. Bring up a new service and you don't touch the proxy config:

services:
  invoices:
    image: ghcr.io/example/invoices:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.invoices.rule=Host(`api.example.com`) && PathPrefix(`/invoices`)"
      - "traefik.http.services.invoices.loadbalancer.server.port=3000"

Middleware for rate limiting, auth and circuit breaking comes built in. The cost is that configuration scatters across labels, which makes seeing the whole system at once harder. Config changes also propagate more slowly than in Kong or Istio, a difference you only notice at large scale. If you're on Docker Compose or Kubernetes and services deploy often, this is what you want.

Actual gateways: Kong, Envoy, APISIX

These answer a different question: API key management, quotas, per-plan rate limits, APIs exposed to external developers, service mesh data planes. Envoy makes sense at very high scale and inside a mesh, since it's what Istio runs on.

The honest read is that for a team with fifteen services, these are overkill. Kong and Envoy earn their keep when the API is a product you sell, or when several teams need to share one entry policy. There are setups running fifty services through Traefik in about 30 MB of memory. If that's your situation, a gateway layer mostly returns maintenance work.

Runtimes

Node. The default. Billions of production hours, the full ecosystem, mature profiling and observability. Under complex business logic and many database calls, its latency behaviour is predictable.

Bun. Ahead on raw throughput, and most compatibility complaints were resolved across 1.2 and 1.3. Teams have moved services from Node and cut latency substantially. On the other hand, under heavy I/O and complicated logic its tail latency can be less consistent than Node's. Don't decide on average response time — look at p99.

Deno. Permission-based security model and TypeScript by default. Makes sense where sandboxing boundaries matter and for work close to the edge.

Cloudflare Workers and similar. Cold starts effectively don't exist and geographic distribution comes for free. In exchange, CPU time is capped, parts of the Node API are missing, and long or memory-hungry work doesn't fit. The test: small per-request work where latency matters, yes; generating reports or processing files, no.

Choosing the runtime before the framework produces less regret than the reverse.

Microservices, or one service

For most teams microservices are unnecessary and expensive, and it's worth saying that plainly before the criteria.

Splitting genuinely pays off when:

  • The parts scale differently. One endpoint taking millions of requests a day next to one taking a hundred is a real reason to scale them separately.
  • You need a different language or runtime. Image processing in Go, everything else in TypeScript.
  • Deploy rhythms diverge. Payments ships monthly, promotions ships three times a day; separating them relieves both.
  • There's a team boundary. Separate teams wanting separate pipelines is an organisational reason rather than a technical one, and it's among the most valid.

Don't split when:

  • One team, one database. Services that all write to the same database aren't microservices, they're a distributed monolith, and you get the drawbacks of both models.
  • The reason is "it'll be cleaner." Drawing a module boundary doesn't require drawing a network boundary. You can enforce boundaries inside one process.

If you do split, settle three things before you start. First, whether services talk over synchronous HTTP or through a queue; the longer a synchronous chain gets, the more one slow service slows everything behind it. Second, distributed tracing has to go in on day one, because retrofitting it is far worse. Third, the contract between services needs to live somewhere written down, which is exactly where Fastify generating OpenAPI from schemas turns out to matter.

If you're not in JavaScript

Most of this went through the JS ecosystem, but the entry layer and the UI strategy are language-agnostic.

On the PHP side, FrankenPHP sits in an interesting spot: it's built on Caddy, so the application server and the reverse proxy ship as one binary with automatic HTTPS included. Against the classic PHP-FPM plus Nginx pairing, that's fewer moving parts to operate. Octane addresses the same process-boot overhead from a different angle.

In Go, the standard library's net/http is production-ready on its own, and most services never develop a real need for a framework on top. Shipping a single binary simplifies deployment too.

The point being: if the language is already decided, half this discussion is settled. What's left is the entry layer and the UI strategy, where everything above still applies.

What I'd pick, by situation

One VPS, a few services, thin UI. Caddy plus Node plus Fastify, with the interface served from the same service. Lowest maintenance burden of any setup here, and most likely all you need.

Docker Compose, ten-plus services, frequent deploys. Traefik plus Node. The framework depends on what the service does: Fastify where contracts dominate, Hono where there are many small endpoints.

Geographically spread users, small fast API. Hono on Workers. Move the heavy work, meaning reports, file handling and long computations, into a separate service off the edge.

Deployment target undecided, UI and API together. Nitro. Swapping a preset later is cheaper than changing an architecture later.

Multi-team, API sold externally. A gateway layer, Kong or APISIX, genuinely earns its place. Building the same thing at smaller scale creates more problems than it solves.

There's no permanent right answer in that list, but there is a repeating mistake: picking the tool before the requirement. Plenty of teams land on Kubernetes, a gateway and microservices well before the scale that justifies any of them, then carry the cost as permanent maintenance in the name of scalability. Erring the other way, starting simple and splitting only when something forces you to, is much cheaper to undo.

More to read