Which layer do you actually pick each tool for?
Every new service starts the same argument. Node or Bun? Hono or Fastify? Nginx in front, or Caddy? Should the interface be its own application, or come out of the same one?
All fair questions, but asked in the wrong order they grow much bigger than they need to be. Most of these tools are not alternatives to each other. They sit at different layers of the same application and do different jobs. Nginx stands in front and takes the incoming request, Node runs behind it, and Fastify or Hono sits on top of Node. So before asking which one is better, it helps to ask what problem you are actually solving.
When I set up a service I start with where it is going to live. Then the runtime, then the application layer, and last of all what goes in front of it. Whether the interface becomes a separate application, and whether there is a real reason to split services at all, gets decided around those same choices. The article follows that order: runtime first, then the application layer, then the entry layer, and finally the interface and the question of splitting services.
Our own work varies from project to project. Some of them have a full server-rendered interface built with React and TanStack Start or Inertia. In others the real work happens in the API, the user sees a handful of screens, and those screens stay at forms, tables and a few buttons. When I say "thin interface" further down I mean that second group, because how much room the interface takes up is what shifts these choices the most. And a team running a few services on one small server and a system where dozens of services ship from different teams were never going to pick the same tools anyway.
The layers a request passes through
A request makes a few stops between the browser and the database:
flowchart LR
client([client])
entry["entry layer<br/>Nginx · Caddy · Traefik"]
runtime["runtime<br/>Node · Bun · Deno · Workers"]
app["application<br/>Hono · Fastify · Elysia · Express"]
db[("database")]
client --> entry --> runtime --> app --> db
The decisions run in the opposite direction. I cannot pick a runtime without knowing where the thing will be deployed, and until the runtime is settled I do not know which frameworks are still on the table. The entry layer comes last, because you choose it based on how many services you have and how often they ship.
Separating the layers takes a lot of heat out of the discussion. You never have to choose between Nginx and Hono, for instance. One accepts the request from outside, the other runs the application itself. Racing Nitro against Fastify on speed does not lead anywhere useful either: Fastify is a web framework running directly on Node, while Nitro takes your server code and packages it for different environments.
Runtime
Node
Node is the safest default. The ecosystem is enormous, there are several options for everything from database drivers to monitoring, and everybody knows how it behaves in production. If the job is to take a request, run a few database queries, call some other services and return the result, Node has no obvious weakness.
Bun
What makes Bun interesting is how much it tries to solve in one package. The runtime, the package manager, the bundler and a set of Web APIs all come from the same ecosystem, and it can post strong performance numbers.
Just keep benchmarks and real applications apart. A router being quick in a benchmark does not mean the service that fires five database queries and waits on a third-party payment API speeds up by the same factor. If you are moving to Bun, the thing worth testing is whether your libraries and native dependencies actually work on it.
Deno
Deno stands out through its permission model, its closeness to Web APIs and its TypeScript experience. That model earns its place in projects where you want to explicitly limit what the application can reach — the file system, the network, environment variables. Even so, a single runtime feature should not decide your framework for you.
Cloudflare Workers and other edge runtimes
Edge runtimes let you run the application close to the user, which shows up in small requests and in APIs where latency matters. In exchange you have to live inside the platform's model for CPU, memory, available APIs, file system access and long-running work.
The practical split: if the work per request is small and the distance between user and server matters, edge makes sense. If you need to generate large reports, run long calculations or push files around, that work belongs in a separate worker or a conventional service.
The application layer
Hono
Hono's core idea is to stay as close to web standards as it can. It works through Request, Response and similar Web APIs, which is what lets the same application code run on Node.js, Bun, Deno, Cloudflare Workers and AWS Lambda.
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
There is an important distinction here. Hono being portable does not make your application portable. The moment you reach for fs, a Node-specific API, a native module or a long-running operation, the runtime boundaries show up. The framework can stay on web standards while the rest of your code does not.
The middleware and plugin world that grew around Node over the years is not all available behind Hono either. That rarely matters on a new project, but it counts when you are moving an existing Express application. For small and mid-sized APIs, services headed for the edge, and applications whose deployment target might change, it is a good starting point.
Fastify
If you are staying on Node, Fastify is my default, and the reason is schemas rather than throughput. You get request validation and response serialisation out of JSON Schema.
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 is not just documentation. Fastify uses it during serialisation, so if a stray passwordHash ends up on the response object it never reaches the output, because the schema does not mention it. Once services start calling each other, that property is worth more than the performance gap. You can also generate an OpenAPI document from the same schemas with @fastify/swagger.
The plugin system is mature and lets you draw real boundaries inside the application. It asks for a bit more discipline in return, and writing schemas can feel like extra work on a small project. As the number of services grows, having the request and response contracts written down pays that back.
Elysia
Elysia is built for Bun, and the developer experience it gets out of its type system and schema definitions is genuinely good. The trade is that your framework choice binds itself to your runtime choice. Picking Elysia means picking Bun.
If you are already on Bun, that is perfectly reasonable. If you have not settled the runtime yet, moving to Bun purely for the typing experience is not a strong enough argument for me. For a service that might run on more than one runtime Hono is the calmer choice, and for one staying on Node, Fastify is.
Express
Express is still everywhere and still mature. Express 5 is under active development, so "nobody uses Express anymore" is not a fair statement. If an Express service is running fine, there may be no technical reason to move it just because newer frameworks exist. Its ecosystem remains its strongest card: whatever middleware, example or integration you are looking for, chances are it exists.
Starting a new service is a different question, and there Fastify's schema and plugin model or Hono's Web API approach give me a better foundation. Leaving Express behind and not choosing Express for something new are two separate decisions.
Nitro
I put Nitro at the end of this list, but it does not belong in the same category, so it is worth judging separately. Rather than an HTTP router or a classic web framework, Nitro is a layer that builds your server code and prepares it for different deployment environments.
Nitro is what Nuxt runs on the server, and it is not tied to Nuxt alone. SolidStart and Analog build on it too, and you can use it directly with Vite:
import { defineConfig } from 'vite'
import { nitro } from 'nitro/vite'
export default defineConfig({
plugins: [nitro()],
nitro: {
serverDir: './server',
},
})
What you get is deployment freedom. If you want your application code to stay as loosely tied to one hosting provider's model as possible, being able to build for different targets through presets is genuinely useful.
The toolchain grows in return. When something breaks in a plain Node service written with Fastify, you look at your code and at Node; with Nitro there is a build and packaging layer in between. If you want SSR in a Vite-based application, or you might move the same server code to a different target later, that cost is fair. If you are running a handful of API endpoints on a single Node server, Fastify is the shorter road.
The entry layer
Nginx
Writing Nginx off as "an old web server" would be a mistake. It is still a very capable reverse proxy and it is everywhere in production. We mostly use it exactly that way: the request hits Nginx first, TLS terminates there, and it gets passed to the right application.
server {
listen 443 ssl;
server_name api.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The applications keep running on their own ports and only Nginx faces the outside world. From the same layer you can route several services across domains or paths, balance load, serve static files and cache responses.
That level of control is Nginx's real strength. Proxy timeouts, request sizes, headers, buffering, compression and connection handling are all adjustable in detail. On a system with a fixed topology, or anywhere you want to keep a close grip on the proxy layer, it is still an excellent choice.
Caddy
Caddy shortens both the configuration and the certificate work. You name the domain, it gets the certificate and renews it before expiry. Setting up the same two services with each tool shows the difference:
api.example.com {
reverse_proxy localhost:3000
}
admin.example.com {
reverse_proxy localhost:4000
}
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
With Nginx you also write the certificate paths and bring in something like certbot. Both do the same job, and the gap is not speed but how much configuration it takes to get there. On a single server running a few services you feel that difference daily. Unless you need some very specific proxy behaviour, Caddy is worth trying first on a new box.
Traefik
What sets Traefik apart is not its proxying but how it finds services. On Docker it reads routing information from container labels, and on Kubernetes it builds configuration from resources:
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"
Deploying a new container no longer means touching a proxy config file. Its middleware system covers rate limiting, authentication and header rewriting as well. The cost is that configuration scatters across labels, so as the system grows it gets harder to track which service owns which route. If you run services on Docker Compose or Kubernetes and deploy often, Traefik earns its place; with two services on one box, Caddy's simplicity is worth more.
Kong, Envoy, APISIX
These also sit at the entry layer but answer a different need: API gateways, central traffic policy, and larger distributed systems. If you need API key management, rate limit policies, quota tracking, control over service-to-service traffic, or one shared entry policy across several teams, a gateway layer starts making sense. Envoy is also a cornerstone of the service mesh world.
For a single team with ten services, adding a gateway because it "might be needed later" is usually a bad trade. Every feature a gateway brings is also another surface to configure, observe and maintain. Adding one when the need actually appears is the easier path.
Serving the interface
In our case the interface is not the product, it is the side of the service the user sees. Making that distinction early makes the rest of the decisions easier. There are three approaches.
Static files plus a separate API
You build the interface and serve it from a CDN or a web server, with the API somewhere else. Technically simple, but two origins bring cookie settings, CORS, preflight requests, authentication and deployment coordination along with them. None of that is unsolvable; it is just a high price for a five-screen admin panel.
SSR from the same application
The interface and the API live inside the same application. An SSR framework like Nuxt, Next or SvelteKit does the work here, and in Nuxt's case Nitro is already running underneath. A single origin keeps authentication and cookie handling simple in the browser, and since the first HTML comes from the server you may not need a large JavaScript application on the client at all. The build system and the application itself get heavier in exchange.
HTML straight from the server
If the interface really is small, you can keep it simpler still. A template system like Blade on the PHP side, or any server-rendered HTML approach, with a bit of JavaScript where it is needed. No separate frontend deployment, no second dependency tree, no chance of the API and the interface drifting out of version with each other.
If the screens fit on one hand, this is what I reach for first. Should the interface grow later, moving to a separate frontend is possible precisely because the API already stands on its own as a contract. Going the other way — setting up two deployments and later admitting you did not need them — is the harder path.
Microservices, or one service
Most teams need microservices later than they think. The genuine reasons to split are these:
- The parts scale differently. One service takes heavy traffic while another gets almost none, so scaling them separately is worth it.
- A different runtime or language is needed. Image processing in Go, the rest of the application in TypeScript.
- Deploy rhythms diverge. One part ships several times a day while another changes monthly.
- There are team boundaries. Two teams want to deploy independently and own their own service.
Without those, splitting mostly costs you. With one team, one database and tightly coupled logic, separating services that all write to the same database produces a distributed monolith. Drawing module boundaries inside a single process is usually cheaper.
If you do split, settle three things up front. First, how services talk: make everything synchronous HTTP and a single request can hop through five services, where one slow link drags the whole chain; move everything onto queues and you take on eventual consistency and operational complexity instead. Second, observability — to follow a request from A to B to C you will want tracing and central logging. Third, the contract: what JSON goes back and forth between services has to be written down somewhere, and this is where OpenAPI or JSON Schema pays off.
If you are outside JavaScript
Most of this went through the JavaScript ecosystem, but the entry layer and the interface strategy are language-agnostic.
On the PHP side FrankenPHP is an interesting option. It is built on Caddy, so the web server, the reverse proxy and the PHP application server come together around a single binary. With Laravel, that removes moving parts compared with the classic PHP-FPM and Nginx pairing. Laravel Octane addresses the same boot cost from another angle by keeping the application alive in long-running worker processes.
In Go the situation is simpler still. You can write a production service directly against net/http, and most backends never develop a need for a framework on top. Shipping a single binary makes deployment easier too.
If the language is already decided, most of the runtime section is settled for you. What remains is the application layer, the entry layer, and how you serve the interface.
Summary table
The practical version of everything above:
| Scenario | First choice | Alternatives |
|---|---|---|
| One server, a few services, thin interface | Caddy + Node + Fastify | Nginx (if you need detailed proxy control), Hono |
| Docker Compose, many services, frequent deploys | Traefik + Node + Fastify | Nginx (fixed topology), Hono |
| Small API, geographically spread users | Hono + Cloudflare Workers | Node + Fastify, with heavy work split off |
| Vite app, SSR plus multiple deploy targets | Nitro | Nuxt (Nitro already underneath), separate frontend + API |
| Contract-heavy API on Node | Fastify | Hono (if edge is needed), NestJS |
| Project already committed to Bun | Elysia | Hono (if you want runtime independence) |
| Existing Express service that works | Express, left alone | Fastify or Hono for new services |
| Multi-team, API sold externally | Kong or APISIX | Envoy (with a mesh), Traefik at smaller scale |
| Interface under five screens | Server-rendered HTML | SSR from the same application |
No row in that table is permanently correct. The mistake that keeps repeating is picking the tool before the requirement. A simple setup can usually be grown later; an unnecessarily complicated one is much harder to simplify.