Plugins, Encapsulation and Decorators
1. What makes Fastify faster than Express, and where does the speed come from?
Most of the gain comes from work Fastify does once at startup instead of on each request. Routes go into find-my-way, a radix-tree router.
Each route's JSON schemas are compiled into validation functions by Ajv and into serializer functions by fast-json-stringify before the server listens.
Hooks are stored per route, so a request walks a precomputed list instead of a global middleware chain.
A strong candidate is specific about where the savings show up:
- Response serialization. A route with a response schema uses a generated function instead of
JSON.stringify, which is the largest single win on JSON-heavy APIs.
- Routing. Lookup cost depends on path length, not route count. Since 5.12.8,
routerOptions.compile: true turns each method's tree into generated JavaScript for faster lookups.
- Logging. Pino writes JSON with low overhead, and Fastify creates a child logger per request instead of formatting strings.
A weak answer says "Fastify is faster" and stops.
A senior candidate adds that the database and outbound calls dominate most real latency, so the framework matters most for high request rates, many small JSON responses or tight CPU budgets per instance.
2. What is encapsulation in Fastify?
Each call to register creates a child context that inherits everything from its parent, while nothing it adds leaks upward or to siblings.
Decorators, hooks, schemas, content type parsers and error handlers registered inside a plugin only apply to routes in that plugin and its descendants.
The Encapsulation reference describes the result as a tree of contexts, with the root instance at the top.
The practical use is isolation: an authenticated subtree can add an auth hook without touching public routes, and two plugins can each register a decorator named db pointing at different databases.
The onClose hook is the one exception the docs list, because it is not encapsulated.
3. What does this return for GET /root?
import Fastify from 'fastify'
const app = Fastify()
app.register(async function child (instance) {
instance.decorate('foo', 'bar')
instance.get('/child', async function () {
return { foo: this.foo }
})
})
app.get('/root', async function () {
return { hasFoo: this.hasDecorator('foo'), foo: this.foo ?? null }
})
await app.ready()
GET /root returns {"hasFoo":false,"foo":null}, and GET /child returns {"foo":"bar"}. The decorator lives in the child context, so the root cannot see it.
Inside a handler declared with function, this is the Fastify instance of the context where the route was registered.
Two follow-ups separate levels. If the handler were an arrow function, this would not be the Fastify instance at all. And to expose foo at the root, the child function would need to be wrapped with fastify-plugin, which question 4 covers.
4. When do you wrap a plugin with fastify-plugin?
Wrap it when the plugin's job is to add something the parent must see, such as a database client decorator, a shared auth helper or a global hook. fastify-plugin sets the hidden Symbol.for('skip-override') property, so register runs the plugin in the parent's context instead of creating a new one.
The Plugins reference recommends the module over setting the symbol by hand, because it also checks the Fastify version range and lets you declare a plugin name and dependencies.
import fp from 'fastify-plugin'
import pg from 'pg'
export default fp(async function dbPlugin (app, opts) {
const pool = new pg.Pool({ connectionString: opts.url })
app.decorate('db', pool)
app.addHook('onClose', async () => {
await pool.end()
})
}, { name: 'db', fastify: '5.x' })
Do not wrap feature plugins that hold routes and route-specific hooks. A common mistake in code review is wrapping everything with fp "to be safe", which flattens the tree and makes every hook global.
5. Find the bug: why do the admin routes skip authentication?
// auth.js
export default async function auth (app) {
app.decorateRequest('user', null)
app.addHook('onRequest', async (request) => {
request.user = await verifyToken(request.headers.authorization)
})
}
// app.js
app.register(auth)
app.register(adminRoutes, { prefix: '/admin' })
auth is registered as a normal plugin, so its decorator and hook live in their own child context. adminRoutes is a sibling, so its routes run without the hook and request.user is not even declared there.
Nothing throws at startup, which is why this bug reaches production.
Two correct fixes exist, and a strong candidate explains when to use each. Wrap auth with fastify-plugin if every route in the app needs it.
Or register adminRoutes inside a context that also registers auth, which keeps public routes outside the hook:
app.register(async function protectedScope (scope) {
await scope.register(authPlugin) // fp-wrapped, so it applies to this scope
await scope.register(adminRoutes, { prefix: '/admin' })
})
The hook also throws away failures: if verifyToken returns null for a bad token, the request still continues. Authentication hooks should reply with 401 or throw an error that carries statusCode: 401.
6. How does plugin loading order work, and what do after, ready and await register do?
Fastify loads plugins with avvio, which queues them and runs them in registration order, depth first, when the instance boots.
A plugin's children finish loading before the next sibling starts. await app.register(plugin) (or app.after()) waits for that plugin to load, so code after it can use the decorators it added. await app.ready() loads the whole tree and runs onReady hooks without listening, which is what tests use.
Two errors candidates should recognize: FST_ERR_DEC_AFTER_START when code decorates after the instance has started, and a plugin timeout when a callback-style plugin forgets to call done.
The pluginTimeout default is 10,000 ms. Since v5, a plugin can no longer mix the two styles: an async plugin that also takes done fails with FST_ERR_PLUGIN_INVALID_ASYNC_HANDLER.
7. Why does decorateRequest reject an object value?
A decorator value goes onto the Request prototype, so an object or array would be one shared reference across all concurrent requests.
One request mutating it would change what the next request sees, which the Decorators reference calls a security and memory leak risk. Since v5, Fastify throws FST_ERR_DEC_REFERENCE_TYPE for decorateRequest('user', {}).
The supported pattern is to declare the property with a primitive or null so the object shape stays stable, then assign a fresh value per request in a hook:
app.decorateRequest('user', null)
app.addHook('preHandler', async (request) => {
request.setDecorator('user', await loadUser(request))
})
getDecorator and setDecorator throw FST_ERR_DEC_UNDECLARED for an unknown name, so a typo fails loudly.
A strong candidate also mentions why Fastify wants the property declared up front at all: V8 keeps one hidden class for every request object when properties are added in the same order.