Understand what’s really happening behind Express, Redux, and Koa by building your own middleware system from scratch.

Introduction
If you’ve ever written something like this in Express:
app.use((req, res, next) => {
console.log("Request received!");
next();
});
…you’ve already used middleware, but do you know how it actually works?
Most developers think middleware is a framework feature.
In reality, it’s a simple pattern:
A middleware is a function that takes an input, does something with it, and passes control to the next function in the chain.
By the end of this post, you’ll not only understand how middleware works, but you’ll build it yourself in plain JavaScript, async-safe, and production-style.
1️. What Exactly Is Middleware?
In plain English:
Middleware is a pipeline of functions that process data step by step.
Each function can:
- Modify the data
- Act (logging, validation, etc.)
- Stop the flow
- Or pass control to the next middleware
You can think of it like airport security:
- Check passport
- Scan luggage
- Go through the metal detector
- Board flight
Each step decides whether you move on or not.
That’s literally middleware.
2️. Middleware in the Real World (Examples You Already Know)
You’ve used middleware, you just didn’t call it that:
Express.js
app.use(authMiddleware);
app.use(loggingMiddleware);
app.use(routeHandler);
Redux
const middleware = (store) => (next) => (action) => {
console.log("Dispatching:", action);
next(action);
};
Koa
app.use(async (ctx, next) => {
console.log("Before");
await next();
console.log("After");
});
Different syntax, same pattern:
A chain of functions where each can run something before or after the next.
3️. Building a Simple Middleware Engine (Synchronous Version)
Let’s start with the minimal version.
function createApp() {
const middlewares = [];
function use(fn) {
middlewares.push(fn);
}
function run(context) {
let index = 0;
function next() {
const middleware = middlewares[index++];
if (middleware) middleware(context, next);
}
next();
}
return { use, run };
}
Usage:
const app = createApp();
app.use((ctx, next) => {
console.log("Step 1:", ctx);
next();
});
app.use((ctx, next) => {
console.log("Step 2:", ctx);
next();
});
app.run({ user: "Umar" });
Output:
Step 1: { user: 'Umar' }
Step 2: { user: 'Umar' }
✅ Clean
✅ Simple flow
✅ Works synchronously
4️. Making It Async-Aware (Real-World Behavior)
In real apps, middleware often uses async code to read files, fetch APIs, or wait for database queries.
Let’s make our engine async:
function createApp() {
const middlewares = [];
function use(fn) {
middlewares.push(fn);
}
async function run(context) {
async function dispatch(index) {
const middleware = middlewares[index];
if (!middleware) return;
await middleware(context, () => dispatch(index + 1));
}
await dispatch(0);
}
return { use, run };
}
Now we can handle asynchronous logic naturally:
const app = createApp();
app.use(async (ctx, next) => {
console.log("1️⃣ Checking auth...");
await new Promise((r) => setTimeout(r, 500));
ctx.auth = true;
await next();
console.log("1️⃣ Done auth check");
});
app.use(async (ctx, next) => {
console.log("2️⃣ Fetching data...");
await new Promise((r) => setTimeout(r, 500));
ctx.data = { id: 1, name: "John Doe" };
await next();
console.log("2️⃣ Done fetching data");
});
app.use(async (ctx) => {
console.log("3️⃣ Final handler:", ctx);
});
app.run({});
Output:
1️⃣ Checking auth...
2️⃣ Fetching data...
3️⃣ Final handler: { auth: true, data: { id: 1, name: 'John Doe' } }
2️⃣ Done fetching data
1️⃣ Done auth check
Notice how the flow goes down, then back up, just like Express and Koa.
5️. How It Actually Works (Step by Step)
When you call next()
, the function:
- Moves to the next middleware in the array.
- Executes it with the same
context
. - Waits until it’s done (if async).
- Returns control back to the previous function.
That’s why “before” and “after” code work naturally:
app.use(async (ctx, next) => {
console.log("Before →");
await next();
console.log("← After");
});
Output:
Before →
(next middleware runs)
← After
So each middleware wraps the next, like nested layers of an onion 🧅.
6️. Adding Error Handling
In production systems, you want middleware that can catch and handle errors gracefully.
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
console.error("Error caught by middleware:", err.message);
}
});
app.use(async () => {
throw new Error("Something broke!");
});
await app.run({});
Output:
Error caught by middleware: Something broke!
✅ Centralized error handling
✅ Just like Express’s app.use((err, req, res, next) => …)
7️. Adding Middleware Parameters (Like Express)
You can easily extend the pattern to support req
, res
, and next
:
const expressLike = createApp();
expressLike.use(async (req, res, next) => {
req.user = "Umar";
await next();
});
expressLike.use(async (req, res) => {
res.message = `Welcome ${req.user}`;
console.log(res.message);
});
expressLike.run({}, {});
✅ Flexible
✅ Mimics Express’s request–response cycle
8️. Real-World Use Cases
Middleware isn’t limited to HTTP servers; it’s everywhere.
1. Authentication
Check user credentials before allowing access.
2. Logging
Track when each request or operation happens.
3. Validation
Stop invalid data before it reaches your core logic.
4. Rate Limiting
Prevent too many operations in a short time.
5. Redux Middleware
Intercept actions before reducers handle them.
Example Redux-style middleware:
const logger = (store) => (next) => (action) => {
console.log("Action:", action);
next(action);
};
Yes, same pattern.
9️. Visualizing Middleware Flow
Think of it like this:
Request → [Middleware 1] → [Middleware 2] → [Middleware 3] → Response
↑______________________________↓
Each layer wraps the next, controlling when and how the next one runs.
That’s why we can log “before” and “after” around async calls.
1️0️. Final Version Reusable Middleware System
Here’s the complete, production-ready version:
function createMiddlewarePipeline() {
const stack = [];
function use(fn) {
stack.push(fn);
return this;
}
async function execute(context) {
async function dispatch(i) {
const fn = stack[i];
if (!fn) return;
await fn(context, () => dispatch(i + 1));
}
await dispatch(0);
}
return { use, execute };
}
// Example usage:
const pipeline = createMiddlewarePipeline();
pipeline
.use(async (ctx, next) => {
ctx.logs = [];
ctx.logs.push("Start");
await next();
ctx.logs.push("End");
})
.use(async (ctx, next) => {
ctx.data = "Processing...";
await new Promise((r) => setTimeout(r, 300));
await next();
})
.use(async (ctx) => {
console.log(ctx);
});
pipeline.execute({});
✅ Pure JavaScript
✅ Async-safe
✅ Easily extensible
Why Middleware Matters
Middleware gives your code structure and composability.
It lets you:
- Insert logic without editing core code
- Reuse the same pattern across servers, UIs, or workflows
- Handle async flow cleanly and predictably
It’s how Express handles requests, Redux processes actions, and Koa manages control flow.
Learn it once, use it everywhere.
Conclusion
Middleware isn’t magic.
It’s just a chain of functions that pass control along, but that simple idea powers half the modern JavaScript ecosystem.
✅ In summary:
- Middleware = function +
next()
- Each one can run before and after others
- It creates a clean, reusable, event-driven flow
Once you’ve built middleware yourself, frameworks stop feeling like black boxes.
Call to Action
Have you ever written custom middleware (maybe in Express or Redux)?
Drop your experience in the comments 👇
And if your teammate still thinks middleware is “just for servers”, share this post. They’ll finally get what’s going on under the hood.
Leave a Reply