Module A-8·19 min read

Why Express middleware chains collapse under extreme throughput and how Fastify's Radix tree router with compiled JSON Schema achieves 3x gains.

Written by Jatin Jain Saraf · Architect of SupraScan

Module 7 — Routing Engines at Scale: Vanilla HTTP vs Radix Tree Frameworks

What this module covers: Your ingestion endpoint receives 50,000 requests per second. Before your code runs, the framework has already spent CPU time parsing the URL, finding the matching route, running middleware, and deserializing the body. At high throughput, this overhead is measurable — sometimes it is the difference between handling your load and dropping requests. This module covers why Express's linear middleware scan fails under extreme concurrency, how Fastify's Radix tree router achieves route matching whose cost depends on path length, not on the number of registered routes, and how compiled JSON Schema validation eliminates per-request interpretation overhead.


The Overhead Before Your Code Runs

For a payment gateway receiving a POST to /api/v2/payments/process, the framework must:

  1. Parse the URL (string split, decode percent-encoding)
  2. Find the matching route handler (scan routes or traverse a tree)
  3. Execute middleware chain (authentication, rate limiting, body parsing)
  4. Deserialize the request body (JSON.parse)
  5. Validate the payload (schema check)
  6. Hand control to your handler

At 100 req/sec, steps 1–5 cost microseconds and are invisible. At 50,000 req/sec, they cost milliseconds that compound into measurable throughput limits. The framework is not neutral — it has a throughput ceiling determined by its internal architecture.


Express: Linear Scan Middleware Chain

Express's routing model is a linked list of middleware functions. Every incoming request walks this list sequentially until a matching handler is found.

// What Express does internally for each request:
app.use(cors());                     // check #1
app.use(helmet());                   // check #2
app.use(express.json());             // check #3
app.use(rateLimiter);                // check #4
app.post('/api/v1/auth', handler);   // check #5 — match!
app.post('/api/v1/users', handler);  // never reached for /auth
app.post('/api/v2/payments', handler);  // never reached
// ... 200 more routes

For a request to /api/v1/auth, Express checks: is this cors? Yes, run it. Is this helmet? Yes, run it. Is this json? Yes, parse the body. Is this rateLimiter? Yes, run it. Is the method POST and path /api/v1/auth? Yes — match.

The path-matching cost: Express uses path-to-regexp for route matching. For each route, it compiles the path pattern to a RegExp and tests the incoming URL against it. The test is O(N) in the number of routes.

With 200 routes and the matching route at position 180: every request triggers 180 RegExp tests. At 50,000 req/sec, that's 9 million RegExp executions per second — a measurable CPU load before any application logic runs.

// Measure Express routing overhead directly
const start = process.hrtime.bigint();
app.handle(mockRequest, mockResponse, () => {});
const routingNs = Number(process.hrtime.bigint() - start);
console.log(`Routing overhead: ${routingNs / 1000}μs`);

Typical Express routing overhead on a 100-route app: 15–60μs per request. At 50K req/sec: 750ms–3s of CPU per second just in routing. That's 75–300% of a single CPU core dedicated to route matching.


Radix Tree Routing: O(m) Route Matching, Independent of Route Count

The hotel-concierge analogy: Express checks every name on the guest list at the door, one by one; Fastify's radix tree is a hotel concierge who reads your reservation code once and walks straight to your floor, your hallway, your room.

Fastify uses find-my-way — a Radix tree (compressed trie) router. Instead of scanning routes sequentially, it traverses a tree where common path prefixes are compressed into single nodes.

Routes registered:
  POST /api/v1/auth
  POST /api/v1/users
  POST /api/v1/users/:id
  GET  /api/v2/payments
  POST /api/v2/payments/process
  GET  /api/v2/payments/:id
 
Radix tree structure:
  /api/
    v1/
      auth         → POST handler
      users        → POST handler
        /:id       → POST handler
    v2/
      payments     → GET handler
        /process   → POST handler
        /:id       → GET handler

Matching /api/v2/payments/process:

  1. Does the URL start with /api/? Yes → descend
  2. Does the next segment start with v1 or v2? v2 → descend
  3. Does the next segment start with payments? Yes → descend
  4. Is the remainder /process? Yes → exact match → return handler

4 string prefix comparisons, regardless of the total number of routes. Adding 100 more routes to a different branch (/admin/...) does not change the cost of matching /api/v2/payments/process. The cost of matching is a function of the path's own length (segment count) — roughly O(m) where m is the number of path segments — and is effectively constant with respect to K, the number of registered routes. Route count does not appear in the cost at all, so it is not "logarithmic in K" — it simply doesn't depend on K.


Fastify: Architecture for Throughput

Fastify's design reflects a single principle: minimize overhead on the hot path.

JSON Schema Compilation via ajv

Every time Express parses and validates a request body at runtime, it interprets the validation logic dynamically. Fastify pre-compiles JSON Schema into optimized validator functions at startup using ajv:

// Fastify with compiled schema validation
const fastify = Fastify({ logger: false });
 
// Schema is compiled ONCE at startup — not on every request
const paymentSchema = {
  type: 'object',
  required: ['amount', 'senderId', 'recipientId'],
  properties: {
    amount: { type: 'integer', minimum: 1, maximum: 1000000000 },
    senderId: { type: 'string', pattern: '^[A-Z0-9]{32}$' },
    recipientId: { type: 'string', pattern: '^[A-Z0-9]{32}$' },
    memo: { type: 'string', maxLength: 256 },
  },
  additionalProperties: false,
};
 
fastify.post('/api/v2/payments', {
  schema: {
    body: paymentSchema,
    response: {
      200: {
        type: 'object',
        properties: {
          transactionId: { type: 'string' },
          status: { type: 'string' },
        }
      }
    }
  }
}, async (request, reply) => {
  // By the time this runs:
  // - Route matched via Radix tree (O(m) on path length, independent of route count K)
  // - Body validated via compiled ajv function (no interpretation)
  // - request.body is type-safe and validated
  const payment = request.body;
  const result = await processPayment(payment);
  return result;  // serialized via fast-json-stringify (compiled)
});

What ajv compilation produces: instead of interpreting the schema on every request, ajv generates a JavaScript function like this:

// What ajv generates at startup (conceptually):
function validatePayment(data) {
  if (typeof data.amount !== 'number') return false;
  if (data.amount < 1 || data.amount > 1000000000) return false;
  if (typeof data.senderId !== 'string') return false;
  if (!/^[A-Z0-9]{32}$/.test(data.senderId)) return false;
  // ... etc
  return true;
}
// This runs 5-10x faster than interpreting the schema on every call

fast-json-stringify: Compiled Response Serialization

Standard JSON.stringify is generic — it inspects every key and value at runtime to determine how to serialize them. fast-json-stringify pre-compiles a response schema into a serialization function:

import fastJsonStringify from 'fast-json-stringify';
 
// Compiled ONCE at startup
const serializePaymentResponse = fastJsonStringify({
  type: 'object',
  properties: {
    transactionId: { type: 'string' },
    status: { type: 'string' },
    amount: { type: 'integer' },
    timestamp: { type: 'integer' },
  }
});
 
// Per-request: 2-3x faster than JSON.stringify
const responseBody = serializePaymentResponse({
  transactionId: 'TX123',
  status: 'accepted',
  amount: 5000,
  timestamp: Date.now(),
});

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.