From Callback Hell to Deno: How Backend JavaScript Grew Up

SS Saurav Sitaula

Node.js started as an experiment—running JavaScript outside the browser. Express made it practical. Then came async/await, TypeScript, Fastify, Hono, Bun, and Deno. The backend JavaScript story is a decade-long journey from 'you can't build real servers with this' to powering half the internet. Here's how I lived through all of it.

The First Time I Ran JavaScript Without a Browser

It was early 2019. I’d been writing JavaScript for the frontend—React components, DOM manipulation, fetch calls. JavaScript was a browser language. Everyone knew that.

Then a senior developer on my team ran this:

node -e "console.log('Hello from the server')"

“That’s it,” he said. “Same language. No browser. You can build APIs with this.”

I was confused. JavaScript on the server felt wrong. Like using a butter knife to cut steak. JavaScript was for making buttons clickable, not for handling HTTP requests and talking to databases.

Six years later, I’ve built production backends in Node.js, Express, Fastify, and poked at Deno and Bun. The butter knife turned out to be a Swiss Army knife. But the journey from “this is a toy” to “this runs Netflix” was anything but smooth.

Act I: Node.js and the Callback Era

Node.js landed in 2009. Ryan Dahl looked at the web and saw a problem: servers wasted time waiting. A PHP server handling a database query would block the entire thread until the response came back. One slow query could stall everything behind it.

Node’s pitch: non-blocking I/O. While one request waits for a database response, Node handles other requests. Single-threaded, event-driven, asynchronous by default.

The problem? In 2009, JavaScript’s only mechanism for asynchronous code was callbacks.

const http = require('http');
const fs = require('fs');

http.createServer(function(req, res) {
  fs.readFile('./users.json', function(err, data) {
    if (err) {
      res.writeHead(500);
      res.end('Error reading file');
      return;
    }
    
    parseUsers(data, function(err, users) {
      if (err) {
        res.writeHead(500);
        res.end('Error parsing users');
        return;
      }
      
      filterActiveUsers(users, function(err, activeUsers) {
        if (err) {
          res.writeHead(500);
          res.end('Error filtering');
          return;
        }
        
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify(activeUsers));
      });
    });
  });
}).listen(3000);

Callback hell. The pyramid of doom. Every async operation nested another level deep. Error handling duplicated at every level. Reading this code made my eyes glaze over.

But it worked. And it was fast. Netflix, LinkedIn, PayPal, Uber—they all adopted Node despite the ugly code. Because the performance model was genuinely better for I/O-heavy workloads.

Act II: Express Made It Practical

If Node.js was the engine, Express was the car built around it. TJ Holowaychuk released Express in 2010, and overnight, Node went from “interesting experiment” to “I can actually build things with this.”

const express = require('express');
const app = express();

app.get('/api/users', function(req, res) {
  res.json([
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' }
  ]);
});

app.post('/api/users', function(req, res) {
  const newUser = req.body;
  res.status(201).json(newUser);
});

app.listen(3000, function() {
  console.log('Server running on port 3000');
});

After the raw http.createServer API, Express felt like magic. Routing, middleware, request parsing—all the boilerplate disappeared. It was minimal, unopinionated, and had a plugin for everything.

Express became the default. Not because it was the best technically, but because it was the easiest to start with and the hardest to outgrow. The req, res, next pattern became the mental model for an entire generation of backend JavaScript developers. Including me.

The Middleware Pattern

Express’s real genius was middleware. Every request flows through a chain of functions, each doing one thing:

const express = require('express');
const app = express();

app.use(express.json());

app.use(function(req, res, next) {
  console.log(`${req.method} ${req.path}`);
  next();
});

app.use(function(req, res, next) {
  const token = req.headers.authorization;
  if (!token) return res.status(401).json({ error: 'No token' });
  req.user = verifyToken(token);
  next();
});

app.get('/api/profile', function(req, res) {
  res.json(req.user);
});

Logging middleware. Auth middleware. CORS middleware. Rate limiting middleware. Each one a small, composable function. This pattern influenced every web framework that came after—not just in JavaScript, but across languages.

Act III: Promises and Async/Await Changed Everything

The callback era ended gradually, then all at once.

First came Promises (standardized in ES2015):

app.get('/api/users', function(req, res) {
  db.query('SELECT * FROM users')
    .then(function(users) {
      return enrichWithProfiles(users);
    })
    .then(function(enriched) {
      res.json(enriched);
    })
    .catch(function(err) {
      res.status(500).json({ error: err.message });
    });
});

Better. Flat chain instead of nested pyramid. But still awkward.

Then async/await landed in Node 7.6 (2017), and everything clicked:

app.get('/api/users', async (req, res) => {
  try {
    const users = await db.query('SELECT * FROM users');
    const enriched = await enrichWithProfiles(users);
    res.json(enriched);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

This was the moment JavaScript stopped looking like a hack for server-side code. async/await made asynchronous code read like synchronous code. The mental overhead dropped dramatically.

I remember rewriting an Express app from callbacks to async/await over a weekend. The codebase lost a third of its lines. Not because I removed features—because I removed nesting, error-handling duplication, and callback boilerplate.

Act IV: Express’s Quiet Decline

Express worked. For years, it just worked. And that was kind of the problem—it stopped evolving.

Express 4 came out in 2014. Express 5 was “coming soon” for almost a decade. The world moved forward: TypeScript became standard, ES modules replaced CommonJS, HTTP/2 arrived, and Express… stayed the same.

The cracks showed:

No async error handling out of the box:

// Express doesn't catch async errors by default
app.get('/api/users', async (req, res) => {
  const users = await db.getUsers(); // If this throws, Express hangs
  res.json(users);
});

// You need a wrapper or error-handling middleware
app.get('/api/users', async (req, res, next) => {
  try {
    const users = await db.getUsers();
    res.json(users);
  } catch (err) {
    next(err); // Manually forward to error handler
  }
});

No built-in TypeScript support. You needed @types/express and the types were often slightly wrong or out of date.

No built-in validation, no schema support, no OpenAPI generation. In 2024, having to install and wire up five separate packages for basic API features felt archaic.

The req and res objects were Express-specific. Not standard Request and Response from the web platform. Your Express handlers couldn’t run anywhere else.

Express still powers a massive chunk of the internet. I still use it for quick prototypes. But for new production work, I started looking elsewhere.

Act V: The Alternatives That Grew Up

Fastify: Express, But Faster and Stricter

Fastify positioned itself as the “fast and low overhead web framework.” Built from the ground up for performance, with JSON schema validation baked in:

import Fastify from 'fastify';

const app = Fastify({ logger: true });

app.get('/api/users', {
  schema: {
    querystring: {
      type: 'object',
      properties: {
        page: { type: 'integer', default: 1 },
        limit: { type: 'integer', default: 20 }
      }
    },
    response: {
      200: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            id: { type: 'integer' },
            name: { type: 'string' },
            email: { type: 'string' }
          }
        }
      }
    }
  }
}, async (request, reply) => {
  const { page, limit } = request.query;
  const users = await db.getUsers({ page, limit });
  return users;
});

await app.listen({ port: 3000 });

The schema isn’t just documentation—Fastify uses it to serialize responses faster and validate inputs automatically. Define the shape once, get validation, serialization, and auto-generated Swagger docs.

When I benchmarked our user service, Fastify handled roughly twice the requests per second compared to Express. For a high-traffic API, that’s the difference between two servers and four servers.

Hono: The Ultralight, Run-Anywhere Framework

Hono is the one that made me rethink what a “JavaScript backend framework” even means:

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { jwt } from 'hono/jwt';

const app = new Hono();

app.use('/api/*', cors());
app.use('/api/*', jwt({ secret: 'my-secret' }));

app.get('/api/users', async (c) => {
  const users = await db.getUsers();
  return c.json(users);
});

app.post('/api/users', async (c) => {
  const body = await c.req.json();
  const user = await db.createUser(body);
  return c.json(user, 201);
});

export default app;

It looks like Express, but it runs everywhere: Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge, AWS Lambda. One codebase, any runtime. Because it’s built on Web Standard APIs (Request, Response, fetch), not Node-specific APIs.

The export default app pattern means your app is just a module. No app.listen(). The runtime decides how to serve it. This is a profound shift in how backend JavaScript works.

NestJS: When JavaScript Wanted to Be Spring Boot

For larger teams and enterprise apps, NestJS brought structure:

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  async findAll(@Query('page') page: number = 1): Promise<User[]> {
    return this.usersService.findAll(page);
  }

  @Post()
  @UseGuards(AuthGuard)
  async create(@Body() createUserDto: CreateUserDto): Promise<User> {
    return this.usersService.create(createUserDto);
  }
}

Decorators, dependency injection, modules, guards, pipes. If this looks familiar, it’s because NestJS borrowed heavily from Angular and Spring Boot. It’s Express (or Fastify) underneath, but with an opinionated architecture layer on top.

I have mixed feelings about NestJS. On large teams, the enforced structure prevents chaos. For small projects, it’s like driving a semi truck to the grocery store. But it proved that backend JavaScript could scale organizationally, not just technically.

Act VI: Deno — Starting Over, On Purpose

Ryan Dahl—the creator of Node.js—gave a talk in 2018 called “10 Things I Regret About Node.js.” Then he built Deno to fix those regrets.

The philosophy was radical: what if we started JavaScript server-side development from scratch, knowing everything we know now?

// No package.json. No node_modules. Just import from a URL.
// (Deno also supports npm packages now)

Deno.serve({ port: 3000 }, async (req: Request): Promise<Response> => {
  const url = new URL(req.url);
  
  if (url.pathname === '/api/users' && req.method === 'GET') {
    const users = await getUsers();
    return Response.json(users);
  }
  
  if (url.pathname === '/api/users' && req.method === 'POST') {
    const body = await req.json();
    const user = await createUser(body);
    return Response.json(user, { status: 201 });
  }
  
  return new Response('Not Found', { status: 404 });
});

What stood out to me:

TypeScript is native. No ts-node, no tsconfig.json fiddling, no build step. Write TypeScript, run it directly. This alone saves hours of project setup.

Web Standard APIs everywhere. Request, Response, fetch, URL, crypto—all the browser APIs work on the server. The code I write for Deno looks like the code I’d write for a Service Worker.

Security by default. A Deno program can’t access the filesystem, network, or environment variables unless you explicitly allow it:

# Allow network access and file read, nothing else
deno run --allow-net --allow-read server.ts

The first time I ran a Deno server with --allow-net, it felt paranoid. Then I thought about all the npm packages in my node_modules that could silently read my filesystem or phone home. The paranoia started to feel reasonable.

npm compatibility arrived. Early Deno was idealistic—no npm, import from URLs only. That stance softened. Modern Deno supports npm packages directly:

import express from 'npm:express';
import { PrismaClient } from 'npm:@prisma/client';

This was the pragmatic move Deno needed. The idealism of “replace npm” was noble but impractical. Meeting developers where they are matters more than being architecturally pure.

And Then Bun Showed Up

While Deno rethought the runtime philosophy, Bun rethought the runtime speed. Built from scratch in Zig (not C++), Bun is fast. Aggressively, benchmark-breakingly fast.

# Install packages
bun install  # 10-50x faster than npm install

# Run TypeScript directly
bun run server.ts  # No transpilation step

# Built-in test runner
bun test

# Built-in bundler
bun build ./src/index.ts --outdir ./dist

Bun bundles everything: runtime, package manager, bundler, test runner. It’s Node-compatible (most npm packages just work) but reimplemented for speed.

I use Bun mostly for its package manager now. bun install finishing in under a second after years of watching npm install crawl—that’s a quality-of-life improvement I can’t go back from.

What Actually Matters: The Patterns That Survived

After living through callbacks, promises, async/await, Express, Fastify, Hono, Deno, and Bun, here’s what I noticed: the patterns survived even when the tools changed.

Middleware is forever

// Express (2010)
app.use(authMiddleware);

// Fastify (2017)
app.addHook('onRequest', authHook);

// Hono (2022)
app.use('/api/*', authMiddleware);

// Deno Oak (2020)
app.use(authMiddleware);

The word changes. The concept doesn’t.

Request → Process → Response is forever

Every HTTP framework, in every language, in every era, does the same thing: receive a request, do something, send a response. The ceremony around it changes. The core loop doesn’t.

The ecosystem beats the runtime

Node.js “won” not because it was the best runtime, but because npm had the most packages. Deno and Bun are technically superior in many ways, but they still had to become npm-compatible to gain traction. The ecosystem is the moat.

What I Wish I’d Known Earlier

  1. Learn HTTP, not just Express. Status codes, headers, methods, content negotiation—these are the same in Express, Fastify, Hono, Django, Rails, and Spring. Framework-specific knowledge expires. HTTP knowledge doesn’t.

  2. TypeScript on the backend isn’t optional anymore. Every serious Node.js project I’ve worked on in the last three years uses TypeScript. The type safety catches bugs that unit tests miss. Just use it.

  3. Express is fine for learning and prototypes. Don’t let anyone shame you for using it. But for new production work, Fastify or Hono give you more for less.

  4. The runtime matters less than you think. Node, Deno, or Bun—your users don’t know or care. Pick based on your team’s experience and your deployment target.

  5. The backend JavaScript ecosystem is mature now. ORMs (Prisma, Drizzle), testing (Vitest), validation (Zod), auth (Lucia, better-auth)—the “JavaScript isn’t for serious backends” argument died somewhere around 2020. Nobody told the people still making that argument.

A Decade of Growing Up

Backend JavaScript went from a party trick to a production powerhouse. The path was messy—callback hell, framework churn, runtime wars. But the result is an ecosystem where you can build serious, scalable, type-safe backends with the same language you use on the frontend.

I started by running console.log in a terminal and thinking “that’s cool but useless.” Now I build APIs that handle millions of requests, connect to databases, queue background jobs, and serve users worldwide.

The language didn’t change that much. The ecosystem around it grew up.


P.S. — I still have a node_modules folder from 2019 on an old laptop. It’s 1.2 GB. For an Express app with 12 dependencies. I refuse to delete it—it’s a monument to how far we’ve come. Also, I’m scared of what’s in there.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism