API Optimization Best Practices for Node.js and TypeScript

API Optimization Best Practices for Node.js and TypeScript

👤 Twigo Research Team📅 🔄 Updated ⏱️ 10 min read🏷️ Backend Development👁️ 221 views

A fast, predictable API makes everything built on top of it easier: the web app feels snappier, the mobile app copes better with weak networks, and your team spends less time debugging. This guide collects the practices we apply to Node.js and TypeScript APIs today, with short examples you can adapt to Express, Fastify or Next.js route handlers.

Key takeaways

  • Return one consistent response shape so every client handles success and failure the same way.
  • Validate input at the edge with a schema library such as zod, and type your errors instead of throwing strings.
  • Paginate every list endpoint, preferably with cursors, and fix N+1 queries before adding hardware.
  • Cache at the right layer: HTTP caching and ETags for clients, Redis for expensive server work.
  • Add rate limiting and observability (structured logs, metrics, traces) before you need them.

1. Use a consistent response envelope

Clients should never have to guess whether data is at the top level or nested, or whether errors arrive as strings or objects. Pick one shape and use it everywhere.

// src/lib/http.ts
export type ApiSuccess<T> = { ok: true; data: T; meta?: Record<string, unknown> };
export type ApiError = { ok: false; error: { code: string; message: string; details?: unknown } };
export type ApiResponse<T> = ApiSuccess<T> | ApiError;

export const ok = <T>(data: T, meta?: Record<string, unknown>): ApiSuccess<T> => ({ ok: true, data, meta });
export const fail = (code: string, message: string, details?: unknown): ApiError => ({
  ok: false,
  error: { code, message, details },
});

The ok discriminant lets TypeScript narrow the type on the client, so res.data is only available after checking res.ok. Keep HTTP status codes meaningful as well: the envelope complements them, it does not replace them.

2. Type your errors

Throwing generic Error objects loses information. Define a small set of application errors that carry an HTTP status and a stable code, then translate them in one place.

// src/lib/errors.ts
export class AppError extends Error {
  constructor(
    public readonly status: number,
    public readonly code: string,
    message: string,
    public readonly details?: unknown,
  ) {
    super(message);
  }
}

export const notFound = (what: string) => new AppError(404, 'NOT_FOUND', `${what} not found`);
export const badRequest = (details: unknown) => new AppError(400, 'VALIDATION_FAILED', 'Invalid request', details);
// Express error handler (register it after all routes)
import type { ErrorRequestHandler } from 'express';
import { AppError } from './lib/errors';
import { fail } from './lib/http';

export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
  if (err instanceof AppError) {
    return res.status(err.status).json(fail(err.code, err.message, err.details));
  }
  req.log?.error({ err }, 'unhandled error');
  return res.status(500).json(fail('INTERNAL', 'Something went wrong'));
};

Never send stack traces or raw database messages to clients. Log them on the server with a request ID instead.

3. Validate input at the edge

Validate request bodies, route params and query strings before they reach business logic. A schema library gives you runtime checks and TypeScript types from a single definition.

import { z } from 'zod';
import { badRequest } from './lib/errors';

const CreateOrder = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().min(1).max(100),
  couponCode: z.string().trim().max(32).optional(),
});
export type CreateOrderInput = z.infer<typeof CreateOrder>;

export function parseCreateOrder(body: unknown): CreateOrderInput {
  const result = CreateOrder.safeParse(body);
  if (!result.success) throw badRequest(result.error.flatten());
  return result.data;
}

Rejecting bad input early protects the database, keeps handlers simple and gives clients precise error messages.

4. Paginate every list and avoid N+1 queries

Unbounded list endpoints are one of the most common causes of slow APIs. Always set a default and a maximum page size. Cursor pagination (by a sortable, unique column) stays fast on large tables and does not skip or repeat rows when data changes, unlike offset pagination.

// GET /orders?limit=20&cursor=<lastId>
const ListQuery = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
  cursor: z.string().optional(),
});

app.get('/orders', async (req, res) => {
  const { limit, cursor } = ListQuery.parse(req.query);
  const rows = await db.order.findMany({
    take: limit + 1,
    ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
    orderBy: { id: 'asc' },
    include: { customer: true }, // load related rows in the same query
  });
  const hasMore = rows.length > limit;
  const items = hasMore ? rows.slice(0, limit) : rows;
  res.json(ok(items, { nextCursor: hasMore ? items[items.length - 1].id : null }));
});

The include above (Prisma syntax, as an example) is the fix for the N+1 problem: loading each order's customer in a separate query inside a loop multiplies database round trips. Use joins, include/populate, or a batching helper such as DataLoader for GraphQL. Also make sure the columns you filter and sort by are indexed.

5. Cache at the right layer

HTTP caching is free performance. For public, rarely changing data, send Cache-Control headers so browsers and CDNs can reuse responses. For data that changes, use ETags so clients can revalidate cheaply and receive 304 Not Modified.

import { createHash } from 'node:crypto';

app.get('/products/:id', async (req, res) => {
  const product = await getProduct(req.params.id);
  const body = JSON.stringify(ok(product));
  const etag = `"${createHash('sha1').update(body).digest('base64url')}"`;

  res.setHeader('ETag', etag);
  res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
  if (req.headers['if-none-match'] === etag) return res.status(304).end();
  res.type('application/json').send(body);
});

Server-side caching with Redis helps when a response is expensive to compute, such as aggregations or slow third-party calls. Cache with a sensible TTL and delete the key when the underlying data changes.

async function cached<T>(key: string, ttlSeconds: number, load: () => Promise<T>): Promise<T> {
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit) as T;
  const value = await load();
  await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
  return value;
}

Never put personalised responses in shared caches; mark them Cache-Control: private or no-store.

6. Rate limit and protect the API

Rate limiting protects you from abuse, accidental client loops and runaway infrastructure costs. Limit per API key or user where you can, and per IP for anonymous endpoints such as login and OTP. With more than one server instance, store counters in Redis rather than in memory.

import rateLimit from 'express-rate-limit';

app.use(
  '/auth',
  rateLimit({ windowMs: 15 * 60 * 1000, limit: 20, standardHeaders: 'draft-7', legacyHeaders: false }),
);

Also set request body size limits, timeouts on outbound HTTP calls, and response compression for large JSON payloads.

7. Observe everything: logs, metrics and traces

You cannot optimise what you cannot see.

  • Structured logs: log JSON (for example with pino) and attach a request ID to every line, so a single request can be followed end to end.
  • Metrics: track request rate, error rate and latency percentiles (p50, p95, p99) per route. Averages hide slow outliers.
  • Traces: OpenTelemetry provides Node.js instrumentation for HTTP servers, database drivers and Redis clients. Traces show exactly which query or external call makes a request slow, and most monitoring tools accept OpenTelemetry data.
import pino from 'pino';
import { randomUUID } from 'node:crypto';

const logger = pino();

app.use((req, res, next) => {
  const id = (req.headers['x-request-id'] as string) || randomUUID();
  req.log = logger.child({ requestId: id });
  res.setHeader('x-request-id', id);
  const start = performance.now();
  res.on('finish', () =>
    req.log.info({ route: req.path, status: res.statusCode, ms: Math.round(performance.now() - start) }, 'request'),
  );
  next();
});

Set alerts on error rate and p95 latency, then let the data tell you which endpoint to optimise next.

8. Keep controllers thin

Route handlers should parse input, call a service function and shape the response. Business logic belongs in plain functions that you can unit test without HTTP. This separation also makes it easy to reuse logic from background jobs, webhooks or a future GraphQL layer.

app.post('/orders', async (req, res, next) => {
  try {
    const input = parseCreateOrder(req.body);
    const order = await orderService.create(req.user.id, input);
    res.status(201).json(ok(order));
  } catch (err) {
    next(err);
  }
});

A simple rollout plan

  1. Add the response envelope and error handler, then migrate endpoints one at a time.
  2. Add schema validation to every endpoint that writes data.
  3. Add pagination limits to list endpoints and check the slowest queries for missing indexes and N+1 patterns.
  4. Introduce structured logging with request IDs, then metrics and tracing.
  5. Add caching and rate limiting where the measurements show they are needed.

If you are building the frontend too, pair this with our guide to responsive web design, and see how we choose tools on our technologies page.

Frequently asked questions

How do I make my Node.js API faster?

Start by measuring which endpoints are slow, then fix the causes in order: missing database indexes, N+1 queries, unbounded list endpoints and repeated expensive work. Add pagination limits, load related data in one query, and cache results that are expensive to compute. Only scale servers after these fixes, because most slow APIs are slow because of queries, not hardware.

What is the best way to handle errors in an Express API?

Throw typed application errors that carry an HTTP status and a stable error code, and convert them to responses in a single error-handling middleware registered after all routes. Return a consistent JSON error shape to clients, never expose stack traces or database messages, and log the full error on the server with a request ID for debugging.

Should I use REST or GraphQL for a new API?

Use REST for most business applications, because it is simpler to cache over HTTP and easy for any client to consume. GraphQL is useful when many different clients need different shapes of the same data. The core practices, including validation, typed errors, pagination and observability, apply equally to both styles.

Is cursor pagination better than offset pagination?

Cursor pagination is better for large or frequently changing tables because it stays fast as data grows and does not skip or repeat rows when records are added or deleted. Offset pagination is acceptable for small admin lists where users need to jump to a specific page number and the dataset is modest.

When should I add Redis caching to my API?

Add Redis when measurements show a specific endpoint is slow because it repeats expensive work, such as heavy aggregations or calls to slow external services. Fix database indexes and use HTTP caching headers first, since they are simpler. Use Redis for the remaining hot paths, with clear expiry times and cache invalidation when data changes.

How do I add rate limiting to an API?

Add middleware that counts requests per user, API key or IP address within a time window and rejects excess requests with HTTP 429. Libraries such as express-rate-limit handle this for Express. When you run several server instances, store the counters in a shared store like Redis so limits apply across all instances.

Do I need OpenTelemetry for a small API?

You do not need OpenTelemetry on day one for a small API. Structured JSON logs with a request ID on every line give most of the early value. Add distributed tracing with OpenTelemetry when you run several services or have slow requests you cannot explain from logs, because traces show exactly where time is spent.

Need help with your API?

Twigo designs and optimises APIs for web and mobile apps, from new builds to performance fixes on existing systems. See our API integration services.

T

Twigo Research Team

The Twigo team specializes in providing top-notch business solutions and insights.

Share this post

Subscribe to Our Newsletter

Stay updated with our latest articles, insights, and offers. No spam, just valuable content delivered to your inbox.

By subscribing, you agree to our Privacy Policy

You May Also Like

GEO: How to Get Your Business Found in AI Search

Business Growth

GEO: How to Get Your Business Found in AI Search

AI assistants now answer many questions before anyone clicks a link. Here's how to make sure your business is one they find, trust and mention.

Read Article
How Twigo Helps You Grow Your Business Online

Business Growth

How Twigo Helps You Grow Your Business Online

What Twigo builds, how a project runs from first call to launch, and what you get afterwards to keep your website or app bringing in customers.

Read Article
AI in Your Business: A Practical Guide for Indian SMEs

AI & Automation

AI in Your Business: A Practical Guide for Indian SMEs

A step-by-step guide to using AI in a small or growing business in India: where it pays off first, build vs buy, costs, risks and a 90-day plan.

Read Article