Node.js vs Express is one of those comparisons that sounds more complicated than it really is.
The reason is simple:
Node.js and Express are not competing technologies.
Node.js is the runtime that executes your server-side JavaScript. Express is a web framework that runs on top of Node.js and gives you a more convenient way to build HTTP applications and APIs.
So when someone asks me whether I would choose Node.js vs Express, I usually reframe the question:
Do I want to build directly with Node's HTTP APIs?
or
Do I want Express to handle the repetitive web-layer work?
That is a much more useful question.
In this guide, I’ll show the same API using raw Node.js and Express, then compare routing, middleware, request parsing, error handling, performance, TypeScript and project structure.
The Short Answer: Node.js vs Express
| Node.js | Express |
|---|---|
| JavaScript runtime | Web framework |
| Runs JavaScript outside the browser | Runs inside Node.js |
| Provides HTTP, filesystem, streams, crypto and other platform APIs | Provides routing, middleware and HTTP convenience APIs |
| Can create an HTTP server directly | Simplifies building APIs and web applications |
| No framework architecture imposed | Minimal and intentionally unopinionated |
| No dependency required for basic HTTP | Installed as an npm dependency |
The relationship is roughly:
Operating System
|
v
Node.js
|
v
Express
|
v
Your application
If you remove Express, Node.js still works.
If you remove Node.js, a normal Express application has nothing to run on.
What Node.js Actually Gives You
Node.js is much bigger than its HTTP server API.
The runtime gives us built-in modules and platform features for things such as:
- HTTP and HTTPS servers
- filesystem access
- streams
- buffers
- cryptography
- TCP and networking
- worker threads
- child processes
- URL handling
- timers
- built-in
fetch() - environment-variable support
- testing tools
Express doesn’t replace any of this.
An Express application still uses Node’s runtime, network stack, process model and underlying HTTP implementation.
The official Node.js API documentation is worth browsing because it shows how much functionality belongs to Node itself rather than to a web framework.
Use a Supported Node.js Release
Before worrying about Express versions, I first make sure the application runs on a supported Node.js release.
At the time I’m updating this article, Node.js 24 is an LTS release while Node.js 26 is the Current release. For production applications, the Node.js project recommends using an Active LTS or Maintenance LTS version.
You can always check the current status on the official Node.js release page.
Building an HTTP Server with Node.js Only
You do not need Express to create a web server.
Node includes the node:http module.
import { createServer } from 'node:http';
const server = createServer(
(request, response) => {
response.writeHead(200, {
'content-type':
'application/json'
});
response.end(
JSON.stringify({
message: 'Hello from Node.js'
})
);
}
);
server.listen(3000, () => {
console.log(
'Server running on port 3000'
);
});
That is a perfectly valid HTTP server.
Node receives the request and gives us request and response objects.
Browser
|
v
Node HTTP server
|
+-- request
|
+-- response
For a tiny server, this is refreshingly simple.
The difference starts becoming obvious when the application needs more than one endpoint.
Routing with Raw Node.js
Suppose we want these routes:
GET /health
GET /users
POST /users
GET /users/:id
The node:http module gives us the incoming method and URL, but it doesn’t give us an Express-style route table such as app.get().
We need to decide how requests are matched.
import {
createServer
} from 'node:http';
const server = createServer(
async (request, response) => {
const url = new URL(
request.url ?? '/',
`http://${request.headers.host}`
);
if (
request.method === 'GET' &&
url.pathname === '/health'
) {
return sendJson(
response,
200,
{
status: 'ok'
}
);
}
if (
request.method === 'GET' &&
url.pathname === '/users'
) {
return sendJson(
response,
200,
[]
);
}
response.writeHead(404, {
'content-type':
'application/json'
});
response.end(
JSON.stringify({
error: 'Not found'
})
);
}
);
function sendJson(
response,
statusCode,
data
) {
response.writeHead(
statusCode,
{
'content-type':
'application/json'
}
);
response.end(
JSON.stringify(data)
);
}
server.listen(3000);
This still isn’t bad.
But now imagine 70 endpoints, path parameters, authentication, validation and nested routers.
At that point I am either going to adopt a framework or start writing my own routing framework.
Parsing a JSON Request with Raw Node.js
This is another useful comparison.
Suppose a client sends:
POST /users
{
"name": "Jay",
"email": "jay@example.com"
}
With the low-level Node HTTP API, the request body arrives as a stream.
async function readJson(request) {
const chunks = [];
for await (
const chunk of request
) {
chunks.push(chunk);
}
const body = Buffer
.concat(chunks)
.toString('utf8');
if (!body) {
return {};
}
return JSON.parse(body);
}
Then the route might use it:
if (
request.method === 'POST' &&
url.pathname === '/users'
) {
try {
const body =
await readJson(request);
return sendJson(
response,
201,
{
id: crypto.randomUUID(),
...body
}
);
} catch {
return sendJson(
response,
400,
{
error: 'Invalid JSON'
}
);
}
}
Again, this is not impossible.
It is just code that I probably don’t want to rewrite in every API.
Now Build the Same API with Express
Install Express:
npm install express
Then:
import express from 'express';
const app = express();
app.use(
express.json()
);
app.get(
'/health',
(request, response) => {
response.json({
status: 'ok'
});
}
);
app.get(
'/users',
(request, response) => {
response.json([]);
}
);
app.post(
'/users',
(request, response) => {
const user = {
id: crypto.randomUUID(),
...request.body
};
response
.status(201)
.json(user);
}
);
app.listen(3000, () => {
console.log(
'Server running on port 3000'
);
});
Express has not made Node disappear.
It has given us a higher-level API for the web-specific work.
Raw Node.js
request.method
request.url
request stream
response.writeHead()
response.end()
Express
app.get()
app.post()
request.params
request.body
response.status()
response.json()
For most HTTP APIs, I find the second model easier to read and maintain.
What Express Actually Adds
Express is intentionally small.
The framework mainly gives me a convenient layer for things such as:
- routing
- route parameters
- middleware
- JSON body parsing
- URL-encoded body parsing
- static files
- response helpers
- error middleware
- modular routers
The Express documentation describes an Express application as essentially a series of middleware function calls. That is a useful mental model.
You can read more in the official Express middleware guide.
Middleware Is the Main Express Idea to Understand
Express middleware sits in the request-response pipeline.
HTTP request
|
v
request ID
|
v
authentication
|
v
validation
|
v
route handler
|
v
HTTP response
A middleware function receives:
request
response
next
For example:
function requestLogger(
request,
response,
next
) {
console.log(
request.method,
request.url
);
next();
}
app.use(requestLogger);
Calling next() passes control to the next middleware or route handler.
If middleware sends a response instead, the request-response cycle ends there.
Middleware Order Matters
This is one of the first Express behaviours I make sure someone understands.
These two configurations are not equivalent:
app.use(
authenticate
);
app.use(
'/admin',
adminRouter
);
and:
app.use(
'/admin',
adminRouter
);
app.use(
authenticate
);
Express processes middleware in registration order.
If authentication appears after the router, requests can reach those routes before the authentication middleware gets a chance to run.
I think of an Express application as an ordered pipeline, not simply a collection of unrelated route definitions.
Route Parameters Are Much Cleaner in Express
Suppose I need:
GET /users/95
With Express:
app.get(
'/users/:id',
async (request, response) => {
const userId =
request.params.id;
const user =
await findUser(userId);
if (!user) {
return response
.status(404)
.json({
code:
'USER_NOT_FOUND'
});
}
response.json(user);
}
);
The route declares its intention directly.
That becomes even more useful for routes such as:
/customers/:customerId/orders/:orderId
Express 5 Makes Async Error Handling Better
This is an important difference between current Express and a lot of older Express tutorials.
With Express 5, if an async route handler returns a rejected promise or throws an error, Express forwards that error to error-handling middleware.
app.get(
'/users/:id',
async (request, response) => {
const user =
await findUser(
request.params.id
);
if (!user) {
throw new Error(
'User not found'
);
}
response.json(user);
}
);
Then a centralized error handler can process it:
app.use(
(
error,
request,
response,
next
) => {
console.error(error);
response
.status(500)
.json({
code:
'INTERNAL_ERROR',
message:
'Something went wrong'
});
}
);
In older Express codebases, you may see wrappers created specifically to catch rejected async handlers and call next(error). Express 5 reduces the need for that pattern.
The change is documented in the Express 5 migration guide.
For a deeper production approach to application errors, see my Node.js error handling guide.
Express Does Not Validate Your API for You
This is an important limitation.
Adding:
app.use(
express.json()
);
means Express can parse a JSON request body.
It does not mean that body is valid for your application.
A client can still send:
{
"email": false,
"age": "banana"
}
So I still validate external input at the API boundary.
For example, using Zod:
import {
z
} from 'zod';
const createUserSchema =
z.object({
name:
z.string().min(1),
email:
z.email()
});
app.post(
'/users',
async (request, response) => {
const result =
createUserSchema
.safeParse(
request.body
);
if (!result.success) {
return response
.status(400)
.json({
code:
'INVALID_REQUEST'
});
}
const user =
await createUser(
result.data
);
response
.status(201)
.json(user);
}
);
Express handles the HTTP layer.
Your application still owns its business rules, validation and security decisions.
Express Doesn’t Provide Authentication Either
Express gives us the middleware mechanism, but it does not decide how our users authenticate.
For example:
async function authenticate(
request,
response,
next
) {
try {
const token =
getBearerToken(
request
);
const user =
await verifyToken(
token
);
request.user = user;
next();
} catch {
response
.status(401)
.json({
code:
'UNAUTHORIZED'
});
}
}
app.get(
'/account',
authenticate,
accountHandler
);
Express gives us a clean place to attach authentication.
It does not make authentication secure automatically.
Organizing a Larger Express Application
One thing I like about Express is also one thing that can become a problem: it is very unopinionated.
You can put the entire application in one file.
I wouldn’t.
As an API grows, I prefer a structure where HTTP handling and business logic are separated.
src/
│
├── modules/
│ │
│ ├── users/
│ │ ├── user.routes.ts
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ └── user.schema.ts
│ │
│ └── orders/
│ ├── order.routes.ts
│ ├── order.controller.ts
│ ├── order.service.ts
│ └── order.repository.ts
│
├── middleware/
│ ├── authenticate.ts
│ ├── request-id.ts
│ └── error-handler.ts
│
├── config/
│ └── config.ts
│
├── app.ts
└── server.ts
That is not an Express requirement.
It is an application architecture decision.
This difference matters because choosing Express does not give the application a complete architecture. The team still has to create one.
Using Express Router for Features
Express Router helps avoid putting every endpoint into app.ts.
import {
Router
} from 'express';
const router = Router();
router.get(
'/',
listUsers
);
router.get(
'/:id',
getUser
);
router.post(
'/',
createUser
);
export {
router as userRouter
};
Then mount it:
import {
userRouter
} from './modules/users/user.routes.js';
app.use(
'/users',
userRouter
);
That gives us:
GET /users
GET /users/:id
POST /users
For me, modular routers are one of the points where Express starts paying for itself compared with manually maintaining URL conditionals.
Node.js vs Express Performance
This is where comparisons often become misleading.
Raw Node.js has fewer framework layers, so yes, there is less framework work happening around each request.
But I would not choose an architecture based only on a Hello World requests-per-second benchmark.
A production request may spend most of its time doing this:
Receive request
|
v
Authenticate user
|
v
Query PostgreSQL
|
v
Call payment API
|
v
Write audit log
|
v
Return response
If the database takes 80ms, removing a small amount of routing overhead probably isn’t the first optimization I would make.
I care more about things such as:
- database query performance
- connection pooling
- network latency
- serialization
- caching
- memory usage
- blocking CPU work
- large response payloads
- third-party dependencies
Express itself describes the framework as a thin layer over Node.js rather than something that hides the platform completely.
If extremely high HTTP throughput is a major requirement, I would benchmark realistic candidates with a realistic application workload rather than assuming Express, raw Node or another framework must automatically win.
When I Would Use Raw Node.js HTTP
I wouldn’t say “never use raw Node.”
There are situations where I like avoiding a framework.
For example:
- a very small internal service
- a health or diagnostic server
- a narrowly focused HTTP endpoint
- learning how Node’s HTTP model actually works
- a tool where avoiding dependencies is genuinely valuable
- specialized low-level HTTP behaviour
For something with two endpoints, manually handling them may be simpler than introducing a framework.
But I pay attention to the point where I start rebuilding framework features myself.
"We're not using a framework."
Three weeks later:
custom router
custom body parser
custom middleware chain
custom error handler
custom parameter parser
custom static file handler
At that point, I need a good reason for maintaining all of that code ourselves.
When I Would Choose Express
Express is still a practical choice when I want a conventional Node.js HTTP API without a highly opinionated framework.
I would consider it for:
- REST APIs
- small and medium backend services
- existing Express codebases
- teams already comfortable with Express middleware
- applications where I want architectural freedom
- services where the Express ecosystem already solves required integrations
I especially like Express when I want the web layer to remain small and I am happy making the architectural decisions myself.
When Express May Be Too Minimal
Express gives you freedom.
Freedom becomes less useful when ten developers make ten different architectural decisions.
If I am building a large application with several teams and I want conventions around:
- dependency injection
- modules
- controllers
- guards
- interceptors
- validation
- testing structure
I may prefer something more opinionated, such as NestJS.
That doesn’t mean Express failed.
It means I need the framework to make more architectural decisions for the team.
You can see that style in my production NestJS REST API guide.
What About Fastify and Hono?
Express is not the only lightweight choice anymore.
Depending on the application, I may also evaluate frameworks such as Fastify or Hono.
The decision is not:
Express is old
therefore
use something newer
I would look at the actual requirements:
- deployment environment
- TypeScript experience
- plugin ecosystem
- performance requirements
- validation strategy
- team familiarity
- runtime portability
- existing application architecture
I’ve written separately about this trade-off in my NestJS vs Hono comparison.
Node.js vs Express with TypeScript
TypeScript works with both approaches.
Raw Node:
import {
createServer,
type IncomingMessage,
type ServerResponse
} from 'node:http';
function handleRequest(
request: IncomingMessage,
response: ServerResponse
) {
response.end('Hello');
}
createServer(
handleRequest
).listen(3000);
Express:
import express, {
type Request,
type Response
} from 'express';
const app = express();
app.get(
'/',
(
request: Request,
response: Response
) => {
response.send('Hello');
}
);
app.listen(3000);
The decision to use TypeScript is separate from the decision to use Express.
If you’re setting up a modern Node.js TypeScript project, see my Node.js TypeScript setup guide.
Express Is Not Your Business Logic Layer
This is one architectural mistake I try to avoid.
A route like this starts simple:
app.post(
'/orders',
async (request, response) => {
// validate request
// query customer
// calculate discount
// check inventory
// create order
// process payment
// send email
// publish event
// format response
}
);
Then six months later the route handler is 250 lines long.
I prefer keeping the HTTP layer responsible for HTTP concerns.
Route
|
v
Controller
|
v
Service
|
+----> Repository
|
+----> Payment provider
|
+----> Event publisher
For example:
async function createOrder(
request,
response
) {
const input =
createOrderSchema.parse(
request.body
);
const order =
await orderService
.create(input);
response
.status(201)
.json(order);
}
The controller understands HTTP.
The service understands the business operation.
This also makes it easier to test business logic without creating a fake HTTP request for every test.
Express 4 vs Express 5: One Thing to Watch
If you’re maintaining an older Express application, don’t assume an Express 5 upgrade is only a package-version change.
Express 5 changed several behaviours, including route path syntax.
For example, older wildcard syntax such as:
app.get(
'/*',
handler
);
needs a named wildcard in Express 5:
app.get(
'/*splat',
handler
);
There are also changes involving optional route syntax and removed/deprecated APIs.
If I am upgrading a production Express 4 application, I read the official Express 5 migration guide rather than only changing the version in package.json.
Common Node.js vs Express Misconceptions
“Node.js is a framework.”
No. Node.js is a JavaScript runtime.
“Express replaces Node.js.”
No. Express runs on Node.js and exposes a higher-level HTTP framework API.
“You need Express to create an API.”
No. Node’s built-in HTTP APIs can create an API without Express.
“Express has everything a backend needs.”
No. Express is deliberately minimal. Database access, schema validation, authentication, authorization, logging, queues and most application architecture remain your responsibility.
“Raw Node is always faster, so it is always better.”
Lower framework overhead doesn’t automatically make it the best engineering choice. Performance should be measured against the actual workload, while maintainability and development cost also matter.
“Express automatically gives you a clean architecture.”
No. Express gives you flexibility. Your team still has to decide how code is organized.
Node.js vs Express: Practical Comparison
| Requirement | Raw Node.js | Express |
|---|---|---|
| Run JavaScript on the server | Yes | Uses Node.js |
| Create HTTP server | Built in | Built on Node HTTP |
| Simple routing API | You implement it | Built in |
| Route parameters | You parse them | Built in |
| Middleware pipeline | You design it | Core framework model |
| JSON body parsing | You handle stream/parsing | express.json() |
| Response helpers | Low-level response API | res.json(), res.status() and others |
| Static files | You implement or use another library | express.static() |
| Async route error forwarding | Your design | Supported in Express 5 |
| Authentication | Your responsibility | Your responsibility |
| Runtime validation | Your responsibility | Your responsibility |
| Database layer | Your responsibility | Your responsibility |
| Maximum low-level control | Higher | Still flexible but higher-level |
How I Decide
My decision is usually simple.
If the HTTP surface is tiny and I have a real reason to avoid dependencies, I may use Node directly.
If I’m building a conventional REST API and want routing and middleware without a lot of framework rules, Express remains a reasonable option.
If the application needs stronger conventions across a larger team, I may move toward something such as NestJS.
If performance, schema handling or runtime portability are particularly important, I may also compare Fastify or Hono.
Tiny HTTP service
|
+---- consider raw Node
Traditional flexible API
|
+---- consider Express
Large structured backend
|
+---- consider NestJS
Lightweight / different runtime needs
|
+---- evaluate Fastify / Hono
I don’t choose based on which logo appears most often on social media.
I choose based on how much infrastructure I want the framework to provide and how much I want the team to design ourselves.
If You’re Learning Backend Development, Learn Both Layers
If you’re new to Node.js backend development, I think it is worth building one tiny HTTP server without Express.
Not because I expect you to write every production API that way.
Because it helps you understand what Express is doing for you.
Build this once:
createServer()
request.method
request.url
request stream
response.writeHead()
response.end()
Then build the same API with:
express()
app.use()
app.get()
app.post()
req.params
req.body
res.status()
res.json()
Once you understand both, Express stops feeling like magic.
It becomes what it really is: a useful abstraction over lower-level HTTP work.
Final Thoughts
The most important thing to understand about Node.js vs Express is that there isn’t really a winner.
Node.js is the platform.
Express is one way to build web applications on that platform.
Raw Node gives me more direct control over the HTTP layer, but it also means I own more of the repetitive HTTP infrastructure.
Express removes much of that repetition through routing, middleware and response helpers while deliberately leaving application architecture largely up to me.
That is why I don’t start a project by asking:
“Is Node.js better than Express?”
I ask:
“How much of the HTTP layer do I actually want to build and maintain myself?”
For a tiny service, the answer may be “most of it.”
For a normal API, I would usually rather spend my time on the application than on writing another router and JSON body parser.
Use Node.js to understand the platform. Use Express when its abstraction makes the application simpler.
Continue Learning
If you’re building Node.js backends, these guides continue from the same practical approach:



