Fastify is usually introduced by its benchmark numbers, but the numbers are a downstream effect of a more interesting architectural choice: Fastify is built as a tree of isolated plugin contexts, not a single flat chain like Express, and it compiles schemas ahead of time instead of interpreting them per request.
Both decisions were made specifically to fix problems Express's flat, shared-state model runs into at scale - accidental coupling between unrelated features, and repeated per-request work that could have been done once.
This page is the mental model behind those two decisions: what a plugin actually is, how encapsulation works and when you deliberately break it, and why schema-first design is a performance strategy, not just a validation convenience.
Fastify Basics covers this hands-on with working code; Fastify Plugins goes deep on autoloading and directory conventions built on top of the model described here.
Fastify structures an application as a tree of nested plugin contexts - each register() call creates a new, isolated child scope - and compiles request/response schemas into fast validators and serializers once at startup.
Insight: Encapsulation prevents the "everything shares everything" coupling that makes large Express apps hard to reason about, and ahead-of-time compilation removes per-request validation and serialization overhead that an interpreted approach pays every time.
When to Use This Model: Structuring an app as independent feature modules, deciding where a shared resource (database, auth) should live in the tree, and understanding why schema-defined routes outperform ad-hoc validation.
Limitations/Trade-offs: Encapsulation adds real conceptual overhead for small apps, and forgetting fastify-plugin when you actually wanted shared scope is a common source of "why can't sibling routes see this decorator" bugs.
A Fastify plugin is just a function - async (fastify, opts) => { ... } - registered with fastify.register(), and it's the single unit Fastify is built out of: routes, decorators, hooks, and even the root application itself are all, structurally, plugins.
What makes this different from "just calling a setup function" is encapsulation: every register() call creates a new child context that inherits everything from its parent at the moment of creation, but whose own additions - new decorators, new hooks, new routes - stay invisible to sibling plugins and to the parent.
app.register(async function userRoutes(fastify) { fastify.decorate("cache", new Map()); // only visible inside userRoutes fastify.get("/users", async () => []);}, { prefix: "/users" });// app.cache is undefined here - the decorator never left that child scope
Picture a tree, not a hallway: the root application is the trunk, each register() call grows a new branch, and a branch can see everything its parent trunk already had, but nothing a sibling branch grew independently.
Fastify was built (2016) specifically to address two things Express's flat model doesn't solve well by default: accidental sharing between unrelated features, and the cost of validating and serializing JSON the same way, from scratch, on every single request.
The tree shape isn't cosmetic - it's implemented as a real prototype chain, where each child context prototypally inherits the parent's decorators and configuration at registration time, so additions after that point don't retroactively appear in contexts created earlier.
fastify-plugin (commonly imported as fp) exists precisely to opt out of this: wrapping a plugin with fp() tells Fastify to skip creating a new child context and instead apply that plugin's additions directly to the parent scope - which is exactly what you want for cross-cutting concerns like a database connection or an auth decorator that every route needs.
Bootstrapping this tree asynchronously - since plugins can be async and may need to await a database connection before the next plugin registers - is handled by avvio, the library Fastify uses internally to guarantee plugins finish registering in dependency order before the server starts accepting requests, rather than racing.
Within a single request, Fastify replaces Express's one-chain next() model with a fixed sequence of named lifecycle hooks - onRequest, preParsing, preValidation, preHandler, preSerialization, onSend, onResponse - each a distinct, named stage rather than an undifferentiated position in one long list.
That granularity is what lets schema validation slot in as its own well-defined stage (preValidation) instead of being just another middleware function competing for chain position, the way it would have to be bolted onto Express.
Schema compilation is the other half of the model: a route's schema option is handed to a compiler (AJV by default) once, at startup, producing an optimized validation function and an optimized JSON serializer for that specific shape - so the cost of "does this request match the schema" and "how do I turn this response object into JSON text fast" is paid once per route definition, not once per request.
The plugin tree scales differently than Express's flat chain: a large Fastify app naturally decomposes into one plugin per feature domain (users, orders, billing), each with its own routes, decorators, and hooks that simply cannot leak into a sibling domain by accident - the encapsulation boundary does the isolation work that Express developers have to enforce by convention.
That boundary has a real cost for small apps, though: a five-route prototype gets little benefit from a tree structure and pays a small conceptual tax (remembering when to reach for fastify-plugin, understanding why a decorator "isn't visible" in a sibling file) that a flat Express app never has to think about.
Schema-first design compounds beyond raw speed: because routes declare their request/response shape as JSON Schema, that same schema can generate OpenAPI documentation automatically (@fastify/swagger), giving Fastify a documentation story Express has no equivalent for without bolting on a separate specification layer - see JSON Schema Validation.
Observability benefits from the same structure - onResponse hooks see every request's final status and timing regardless of which plugin handled it, and Fastify's built-in Pino logger (see Logging with Pino) is wired through the same lifecycle rather than added as a separate middleware layer.
Framework's core abstraction
Strength
Weakness
Best Fit
Fastify: encapsulated plugin tree
Isolated scopes prevent accidental coupling; schema-compiled dispatch is fast
Extra structure to learn; easy to misuse fastify-plugin
High-throughput or schema-first APIs; teams that want enforced modularity without a DI container
Express: flat middleware chain
Minimal, familiar, huge ecosystem
No encapsulation; validation typically re-run per request
"A Fastify plugin is basically the same as Express middleware." Middleware is one function in a flat chain; a plugin is a whole isolated context that can itself contain routes, hooks, decorators, and further nested plugins.
"You should wrap everything in fastify-plugin." Doing so removes encapsulation everywhere, which defeats the point - reserve it for things that genuinely need to be shared globally, like a database connection or an auth decorator.
"Schema validation makes Fastify slower than an unvalidated Express route." The opposite is typically true in practice - schemas compile once into optimized functions at startup, which is usually faster than ad-hoc validation logic re-run from scratch on every request.
"Plugin registration order doesn't matter because Fastify handles it."avvio guarantees plugins finish registering before the server starts, but registration still happens in the order you call register() - a plugin depending on a decorator from a sibling still needs that sibling registered first, or lifted with fastify-plugin.
"Lifecycle hooks are just middleware with different names." They're distinct, ordered stages tied to specific points in request processing (before parsing, before validation, before serialization) - not an arbitrary position in one undifferentiated chain the way Express middleware is.
What is Fastify's core architectural idea, in a sentence?
A tree of isolated plugin contexts, each created by register(), combined with JSON Schema definitions compiled once at startup into fast validators and serializers.
What exactly is a Fastify "plugin"?
Any function registered with fastify.register() - routes, decorators, hooks, and even the app itself are all structurally plugins. It's the one unit Fastify composes everything from.
How does encapsulation actually work under the hood?
Each register() call creates a new child context that prototypally inherits everything the parent had at that moment, but any decorators, hooks, or routes the child adds stay invisible to siblings and to the parent - it's implemented as a real prototype chain, not just a convention.
When should I use `fastify-plugin`?
When something genuinely needs to be visible everywhere - a database connection, a shared auth decorator, global configuration. It deliberately skips creating a new child context and applies additions to the parent scope instead.
Why does Fastify compile schemas instead of validating on the fly?
Because a route's request and response shape is known in advance, Fastify hands it to a compiler (AJV by default) once at startup, producing an optimized function - so the validation and serialization cost is paid once per route definition, not repeated on every incoming request.
What is `avvio` and why does Fastify need it?
It's the internal bootstrapping library that manages asynchronous plugin registration, guaranteeing plugins finish loading in the correct dependency order before the server starts accepting traffic - important because plugins can await things like a database connection before the next plugin runs.
How do Fastify's lifecycle hooks differ from Express middleware?
Hooks are named, fixed stages (onRequest, preValidation, preHandler, onSend, and so on) tied to specific points in request processing, rather than an undifferentiated position in one flat chain - which lets Fastify slot concerns like schema validation into a well-defined stage instead of a middleware function competing for chain order.
Is the plugin tree overkill for a small app?
For a handful of routes, yes, largely - the isolation benefits show up as a codebase grows past a few feature domains. A small prototype pays a small conceptual tax for structure it may not yet need.
Does encapsulation ever hurt more than it helps?
It can, when a team doesn't understand it - "why can't this route see that decorator" is a common early confusion until the tree/scope model clicks, at which point it becomes the thing preventing accidental coupling instead.
How does this compare to NestJS's module system?
Fastify's plugin tree is lighter weight - no dependency injection container, no decorator-driven metadata - while NestJS modules add providers, DI-resolved dependencies, and a more elaborate request lifecycle on top of a similar idea of bounded, composable units.
Can a Fastify app still generate API documentation from this model?
Yes - because routes declare their request/response shape as JSON Schema for the compiler, that same schema can drive automatic OpenAPI generation (@fastify/swagger), which is a direct benefit of the schema-first design rather than a bolted-on feature.
Does the plugin tree affect testing?
Yes, favorably - because a plugin is self-contained, it can be registered into a bare Fastify() instance and tested with inject() in isolation, without needing the rest of the application's plugin tree.