Background image

API Advice

Choosing the right JavaScript API framework

Nolan Di Mare Sullivan

Nolan Di Mare Sullivan

December 9, 2024

Featured blog post image

The fast-paced and ever-evolving JavaScript ecosystem, with its plethora of server-side frameworks for building REST APIs in JavaScript and TypeScript, can feel overwhelming when choosing the right tool for your project.

This article explores the key factors for selecting a JavaScript API framework, covering community support, documentation, scalability, request-processing speed, concurrency, and OpenAPI compatibility, and provides a decision-making framework to help you choose between options like Express, NestJS, Fastify, Hono, and ElysiaJS.

Factors to consider when choosing a JavaScript API framework


Iteration speed

Speed is critical for startups and projects with tight deadlines. Selecting a JavaScript framework with a strong ecosystem ensures you can quickly build features and abstract logic without unnecessary complexity. Frameworks that support rapid development and scalability are ideal for MVPs.

For quick iteration and simplicity, consider frameworks like Express or Hono, which enable rapid endpoint creation while leaving room for future enhancements.

Robustness and security

If your MVP has succeeded, or you need to build a secure API for an existing service, robustness and security take priority. To ensure a framework meets these needs, consider the following:

  • Architecture: Look for clear patterns, like dependency injection or layered architectures, to ensure modularity, scalability, and maintainability.
  • Community support: Opt for frameworks with active communities, comprehensive documentation, and a strong ecosystem of plugins.
  • Maintenance: Consider frameworks with stable or LTS versions, regular updates, and consistent security patches.
  • TypeScript support: Strong typing and improved refactoring capabilities make TypeScript invaluable for reducing runtime errors in large-scale projects.
  • Security features: Built-in authentication, access control, and data validation tools simplify secure API implementation and reduce vulnerability risks.

Maturity vs innovation

Bun has reshaped the JavaScript ecosystem by addressing the performance limitations often associated with Node.js. Known for its speed and optimization, Bun reduces the bottlenecks caused by slow Node.js core improvements.

Bun offers faster and safer development with native optimizations and built-in tools, including a JavaScript runtime, bundler, and task runner. However, compared to Node.js, which boasts over 15 years of stability, long-term support policies, and a vast ecosystem, Bun lacks maturity and comprehensive compatibility. Despite this, Bun is steadily improving its compatibility with Node.js libraries, enabling developers to explore its potential.

While frameworks like Express are compatible with Bun, Hono and ElysiaJS are specifically designed to leverage Bun’s capabilities, offering streamlined integration and optimized performance.

So, how do you choose between a newer framework and a more established one?

If stability is essential and you want to avoid tools with an underdeveloped ecosystem or undocumented edge cases, opt for mature frameworks like Fastify, NestJS, or Express.

However, ElysiaJS is worth testing if you already have experience with Bun’s ecosystem. While robust, it’s best suited for developers who are comfortable taking risks and delving into source code to address issues, as error handling and documentation might be limited.

Popular JavaScript API frameworks


Express: Lightweight, simple, and battle-tested

Express (opens in a new tab) is one of the oldest and most widely used JavaScript frameworks for building REST APIs. Valued for its simplicity and minimalism, Express provides a lightweight structure that allows developers to make their own architectural decisions. Its flexible design makes it ideal for projects with evolving or undefined requirements.

Here’s an example of creating an endpoint to fetch a list of products from a database:

index.js
import express from "express";
import { Pool } from "pg";
// Create an Express app
const app = express();
// PostgreSQL database connection pool
const pool = new Pool({
connectionString: 'postgres://user:password@localhost:5432/mydatabase',
});
// One-liner route to get all products
app.get('/products', async (req, res) => {
try {
res.json((await pool.query('SELECT * FROM products')).rows);
} catch (error) {
console.error('Error fetching products:', error.message);
res.status(500).json({ error: 'Internal Server Error' });
}
});
// Start the server
const PORT = 3000;
app.listen(PORT, () => {
console.log(`🚀 Server is running at http://localhost:${PORT}`);
});

You can start the server with the node index.js command. This example demonstrates a basic Express application, but you can organize your code or leverage ecosystem tools to streamline development. Express relies on the middleware design pattern, where middleware processes requests, handles tasks like logging, authentication, or error handling, and passes control to the next middleware or route handler.

Express middleware architecture example

Find a list of middleware modules the Express team maintains in the documentation (opens in a new tab).

Hono: For serverless APIs with essential features

Hono is a modern JavaScript framework tailored for serverless architectures that simplifies development by directly bundling features like authentication, middleware, and validation into the framework.

Why choose serverless for your API?

Serverless architecture reduces developer overhead by abstracting server management. Here are three technical benefits:

  1. Cost efficiency: Pay only for your API’s compute power, making it ideal for MVPs or traffic with unpredictable demand.
  2. Dynamic scaling: Platforms like AWS Amplify and Cloudflare Workers scale automatically during traffic spikes without manual intervention.
  3. Reduced complexity: Serverless manages infrastructure, patching, and scaling, letting developers focus on code.

Hono enhances these serverless advantages by offering built-in authentication, validation, and routing tools, minimizing external dependencies, and streamlining development.

Here is how you can write an API that returns a list of Pokémon, for example:

index.js
import { Hono } from 'hono';
import { logger } from 'hono/logger';
import { cors } from 'hono/cors';
import { swaggerUI } from '@hono/swagger-ui'
const app = new Hono();
// Pokémon data
const pokemons = [
{ id: 1, name: 'Bulbasaur', type: 'Grass/Poison' },
{ id: 2, name: 'Charmander', type: 'Fire' },
{ id: 3, name: 'Squirtle', type: 'Water' },
];
// Middleware: Logger
app.use('*', logger());
// Middleware: CORS
app.use('*', cors());
// Middleware: Swagger UI
app.get('/ui', swaggerUI({ url: '/doc' }))
// Route to return a list of Pokémon
app.get('/pokemons', (c) => {
return c.json(pokemons);
});
export default app;

NestJS: For Robust and Secure Enterprise Applications

As your project scales or if you need a robust and scalable solution from the start, frameworks with strong architectural foundations are the better choice, offering proven design patterns and built-in tools to effectively address security, reliability, and scalability.

The framework that excels in building robust, scalable, maintainable applications is NestJS. Its opinionated architecture enforces best practices, making NestJS ideal for complex systems focused on security and reliability. Built on top of Express or, optionally, Fastify, NestJS provides a structured layer tailored to enterprise needs.

NestJS supports multiple programming paradigms:

  • Object-oriented programming (OOP): Ensures modularity and encapsulation.
  • Functional programming (FP): Promotes clean, declarative logic.
  • Domain-driven design (DDD): Suitable for large, complex applications with intricate domain modeling.

NestJS’s flexibility allows developers to handle simple CRUD operations and advanced business logic without compromising maintainability.

Key built-in NestJS features that simplify development are:

  • Authentication and authorization: Use @nestjs/jwt (opens in a new tab) for authentication and implement role-based access control (RBAC) with guards for enhanced security.
  • OpenAPI documentation: @nestjs/swagger (opens in a new tab) automatically generates OpenAPI-compliant documentation, easing API consumption and integration.
  • Deep TypeScript support: NestJS employs TypeScript’s type system to provide robust type checking and ensure type safety throughout the application.

However, these benefits come with a significant inconvenience: verbosity. Let’s see how we can create a simple API to manage books for a library.

First, you need to define the model. NestJS integrates well with Mongoose.

book.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';
@Schema()
export class Book extends Document {
@Prop({ required: true })
title: string;
@Prop({ required: true })
author: string;
@Prop()
publishedYear: number;
}
export const BookSchema = SchemaFactory.createForClass(Book);

Then, you need to define the data transfer object (DTO), which helps validate incoming data.

create-book.dto.ts
export class CreateBookDto {
readonly title: string;
readonly author: string;
readonly publishedYear?: number;
}

Next, you implement the service as the middleman between your application and the database. Queries to create and retrieve items can be defined here.

books.service.ts
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { Book } from './book.schema';
import { CreateBookDto } from './create-book.dto';
@Injectable()
export class BooksService {
constructor(@InjectModel(Book.name) private readonly bookModel: Model<Book>) {}
async getAllBooks(): Promise<Book[]> {
return this.bookModel.find().exec();
}
async createBook(createBookDto: CreateBookDto): Promise<Book> {
const newBook = new this.bookModel(createBookDto);
return newBook.save();
}
}

With the services defined, you can write the controller that will handle the requests to the REST API.

books.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { BooksService } from './books.service';
import { CreateBookDto } from './create-book.dto';
import { Book } from './book.schema';
@Controller('books')
export class BooksController {
constructor(private readonly booksService: BooksService) {}
@Get()
async getAllBooks(): Promise<Book[]> {
return this.booksService.getAllBooks();
}
@Post()
async createBook(@Body() createBookDto: CreateBookDto): Promise<Book> {
return this.booksService.createBook(createBookDto);
}
}

The BooksController will expose these endpoints:

  • GET /books: Retrieve all books.

  • POST /books: Add a new book.

The defined components are part of the books module, which registers the providers (services), controllers, and schema, ensuring modularity and organization within the application.

books.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { BooksController } from './books.controller';
import { BooksService } from './books.service';
import { Book, BookSchema } from './book.schema';
@Module({
imports: [MongooseModule.forFeature([{ name: Book.name, schema: BookSchema }])],
controllers: [BooksController],
providers: [BooksService],
})
export class BooksModule {}

Finally, you can add the BooksModule to the main module.

app.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { BooksModule } from './books/books.module';
@Module({
imports: [
MongooseModule.forRoot('mongodb://localhost/nest-books'),
BooksModule,
],
})
export class AppModule {}

NestJS’s structured approach, though initially tedious, is justified by its ability to simplify the management of complex applications, even in large teams. Its modular design ensures that every component – models, DTOs, controllers, and modules – has a clear and defined role, reducing ambiguity and enforcing best practices.

This focus on robust architecture and built-in features makes NestJS a trusted choice for critical applications, as evidenced by its use in organizations like Adidas (opens in a new tab) and Société Générale. When robustness and security are essential, NestJS provides the reliability needed.

Fastify: A replacement for Express

JavaScript’s asynchronous core makes it inherently fast, but not all frameworks maximize its potential. A notable limitation of Express is its lack of optimization for asynchronous workflows. Although Express now supports async/await, its core, originally designed for callbacks, doesn’t handle errors in async route handlers automatically.

For example, if an async route handler throws an error or rejects a promise, you must explicitly use a try/catch block or pass errors to next() to invoke the built-in error-handling mechanism. Without this, unhandled errors may crash the application, highlighting Express’s limitations in modern asynchronous processing.

app.get('/products', async (req, res, next) => {
try {
const data = await someAsyncFunction();
res.json(data);
} catch (err) {
next(err); // Pass the error to the Express error handler
}
});

Without try/catch or next(err), the application might crash or fail to handle the error appropriately. In addition, older third-party middleware in Express may not always support async/await out of the box, which can lead to performance bottlenecks, as the architecture doesn’t optimize for high-throughput asynchronous tasks.

This is where Fastify is a framework worth considering, offering syntax and ease of use that closely resemble Express. To illustrate, here is how the example we wrote for the product REST API built with Express can be implemented with Fastify:

index.js
import Fastify from 'fastify';
import { createPool } from './db.js';
// Create a Fastify instance
const fastify = Fastify({ logger: true });
// Import PostgreSQL connection pool
const pool = createPool();
// Route to get all products
fastify.get('/products', async (request, reply) => {
try {
const { rows } = await pool.query('SELECT * FROM products');
reply.send(rows);
} catch (error) {
fastify.log.error(error);
reply.status(500).send({ error: 'Internal Server Error' });
}
});
// Start the server
const startServer = async () => {
try {
await fastify.listen({ port: 3000 });
console.log('🚀 Server is running at http://localhost:3000');
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
startServer();

The GET /products route can also be written using fastify.route. This method helps with schema definition for both the response and request and integrates with Fastify’s built-in support for OpenAPI documentation generation if needed.

// Define the schema for the route
const getProductsSchema = {
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
price: { type: 'number' },
description: { type: 'string' },
},
required: ['id', 'name', 'price'],
},
},
},
};
// Define the route
fastify.route({
method: 'GET',
url: '/products',
schema: getProductsSchema,
handler: async (request, reply) => {
try {
const { rows } = await pool.query('SELECT * FROM products');
reply.send(rows);
} catch (error) {
fastify.log.error(error);
reply.status(500).send({ error: 'Internal Server Error' });
}
},
});

Fastify excels in high-performance server setups by using a refined asynchronous model. Fastify, built on async/await and Promises, ensures non-blocking request handling with exceptional efficiency. Fastify’s key features include:

  • Schema validation: Streamlines request and payload validation, reducing runtime overhead.

  • Logging performance: Integrated with Pino (opens in a new tab) at its core, Fastify provides fast, low-cost logging even under heavy loads.

  • Throughput: Fastify can handle up to 30,000 requests per second, making it one of the fastest frameworks for traditional or containerized setups. That’s 2x to 3x time faster than ExpressJS (opens in a new tab).

Fastify’s speed also comes from using the fast-json-stringify library for efficient JSON handling and a lightweight, plugin-based architecture that optimizes components and connections.

Thanks to its high performance and plugin-based architecture, Fastify is an excellent choice for building fast, lightweight MVPs as it simplifies initial development. Fastify can serve as the HTTP adapter for a more robust framework, like NestJS, making it a strategic choice: Start with Fastify for speed and agility, then migrate to NestJS when requirements for modularity, maintainability, and advanced features like dependency injection and guards become priorities.

ElysiaJS: For speed and developer experience

With the emergence of Bun, the latest and fastest JavaScript runtime, new frameworks and paradigms are redefining the ecosystem. This raises the question: Should you choose a mature or cutting-edge framework?

ElysiaJS (opens in a new tab) is a notable framework optimized for Bun and Node.js environments. Designed for high performance and developer experience, ElysiaJS includes features like route schema definitions that efficiently generate API documentation using tools such as Swagger UI or Scalar, providing a modern, responsive interface for managing APIs.

Here’s an example of an API written in ElysiaJS with OpenAPI schema definitions:

index.js
import process from 'node:process'
import { Elysia } from 'elysia'
import { logger } from '@bogeychan/elysia-logger'
import { swagger } from '@elysiajs/swagger'
import { cors } from '@elysiajs/cors'
import { prisma } from './db'
import { getAllProducts } from './orm'
const app = new Elysia()
.use(logger())
.use(
swagger({
documentation: {
info: {
title: 'Products API',
version: '1.0.0',
description: 'API documentation for retrieving all products.',
},
servers: [
{
url: 'http://localhost:3000',
},
],
tags: [
{ name: 'Products', description: 'Product endpoints' },
],
components: {
schemas: {
Product: {
type: 'object',
properties: {
id: {
type: 'integer',
},
name: {
type: 'string',
},
shopId: {
type: 'integer',
},
},
},
},
},
},
}),
)
.use(cors())
.get(
'/api/products',
async () => {
const products = await getAllProducts()
return products
},
{
detail: {
tags: ['Products'],
summary: 'Get all products',
},
},
)
await prisma.$connect()
app.listen(process.env.PORT as string, () =>
console.info(`🦊 Server started at ${app.server?.url.origin}`))

The code above exposes a GET /api/products API route but also does something interesting: It generates documentation built on Scalar UI (opens in a new tab). Visiting https://localhost:3000/swagger will return a page similar to this:

ElysiaJS Scalar UI

Making pragmatic choices

The JavaScript API frameworks discussed here each have distinct strengths and trade-offs, making it challenging to select the right one for your project. We’ve included a flowchart outlining key factors like performance, scalability, and stability and a decision table that maps everyday use cases to recommended frameworks to assist in this decision.

Use the flowchart to narrow your choices based on your project’s needs and priorities. Then, consult the decision table to find the framework that best matches your requirements.

Flowchart to choose a JavaScript API framework

ScenarioRecommended frameworkWhy?
MVP with limited resourcesHono, FastifyLightweight, fast, and easy to set up. It is ideal for rapid iteration and serverless deployments.
Enterprise-grade applicationsNestJSProvides a structured architecture, robust security, and maintainability for large-scale systems.
Heavily async workloadsFastifyOptimized for high-throughput and non-blocking operations, outperforming Express in concurrency.
Familiarity with Node.js toolsExpressA stable and well-documented ecosystem with extensive middleware options.
Experimenting with BunElysiaJSHigh performance with unique developer tools, leveraging Bun’s runtime for cutting-edge projects.

To be concise, consider:

  • Stability or speed? Express and NestJS for stability; Fastify and ElysiaJS for speed.
  • New or scaling? Hono and Fastify for new projects; NestJS for scaling.
  • Features vs risk? NestJS provides robust features; Hono and ElysiaJS are lightweight and modern.
  • Team expertise? Express suits familiar teams; ElysiaJS is ideal for Bun experts.

For stability, choose Express or NestJS; explore Fastify, Hono, or ElysiaJS for speed and innovation.

CTA background illustrations

Speakeasy Changelog

Subscribe to stay up-to-date on Speakeasy news and feature releases.