skip to content

Plugins and Pipeline

Everything in Ktor is a plugin installed into a pipeline of phases, from CORS and compression to your own interceptors. Understanding the pipeline and its ordering is what lets you place a custom plugin correctly.

on this pageshow

questions

4

In Ktor, what does install() do, and at which scopes can a plugin be installed?

level: juniorimportance: must knowfreq 72%

answer

  1. Nothing cross-cutting is enabled by default
  2. The trailing lambda runs at startup, not per call
  3. One instance per scope, per key
  4. A subtree can have its own policy

basics

~20 s

Ktor's install() adds a plugin to a pipeline and runs its configuration block once at startup. Plugins can be installed application-wide inside a module, or — when the plugin is route-scoped — on a single route so only that subtree is affected.

solid answer

~40 s

In Ktor almost every cross-cutting capability is a plugin, and `install()` is how you attach one. Called on the `Application` inside a module function, `install(Compression) { gzip() }` registers the plugin for the whole server: the trailing lambda is its configuration block, evaluated **once during startup**, not per request. What the plugin then does is subscribe to points in the call pipeline, so every subsequent request passes through it. Some plugins are route-scoped and can also be installed inside a `route("/admin") { install(...) }` block, limiting their effect to that subtree — useful when only part of the API needs, say, a stricter policy. Installing the same plugin twice in the same scope is an error, not a merge, so configuration for one plugin belongs in a single `install` call.

code

kotlin · 11 lines
kotlin
fun Application.module() {
    install(Compression) {
        gzip()
    }
    install(CachingHeaders) {
        options { _, _ -> CachingOptions(CacheControl.MaxAge(maxAgeSeconds = 60)) }
    }
    routing {
        get("/health") { call.respondText("OK") }
    }
}

go deeper

for a junior

Recall the install(Plugin) { config } shape, that it lives inside a module function, and that the block is startup configuration. Be able to name a few built-in plugins.

for a middle

Explain that one configured instance exists per scope, why the config block therefore runs once, and what route scope buys over application scope.

for a senior

Decide deliberately what belongs globally versus per subtree, and keep the module readable by grouping installs into extension functions with clear ownership.

for a principal

Own the platform baseline — which plugins every service installs and how they are configured — so cross-cutting behaviour is consistent instead of copied from whichever template a team started from.

## Plugins are the extension model Ktor's core is deliberately small: an engine, a call pipeline, and routing. Compression, CORS, caching headers, content negotiation, call logging, status pages, request validation — all of it is a *plugin* you opt into. Nothing is on by default, which is why a fresh Ktor app does not, for example, negotiate JSON until you install the plugin that does. `install()` is the single verb for that opt-in: ``` fun Application.module() { install(Compression) { gzip() } install(CORS) { allowHost("example.com", schemes = listOf("https")) allowHeader(HttpHeaders.ContentType) } routing { /* ... */ } } ``` The first argument is the plugin object itself; the trailing lambda is a **configuration block** whose receiver is that plugin's own config type. That block runs exactly once, during application startup. This trips people up: code inside `install(X) { ... }` is setup, not per-request logic. If you want per-request behaviour you either configure the plugin to do it or write a custom plugin whose handlers fire per call. ## What installation actually does Installing stores the configured plugin under a key on the pipeline and lets the plugin register its own handlers at points in the call lifecycle. From then on, each incoming call flows through those handlers before or after reaching your route handler. The plugin is a *singleton per scope*, which is why the config block is evaluated once — the resulting configuration is shared by every request. That also explains why installing the same plugin twice in the same scope is rejected rather than silently merged: the pipeline holds one instance per key, and two `install` calls would leave ambiguity about which configuration wins. Consolidate into one call. ## Scopes **Application scope.** `install(...)` on the `Application` receiver inside a module function. Applies to every call the server handles. This is the common case and where most plugins belong. **Route scope.** Plugins built as route-scoped can be installed inside a `route` block: ``` routing { get("/public") { call.respondText("open") } route("/admin") { install(AdminOnlyPlugin) get("/stats") { call.respondText("secret") } } } ``` Only calls that match that subtree see the plugin. This matters when a policy is genuinely local — an audit log for a sensitive subtree, a stricter payload limit for an upload endpoint — and it keeps the cost off the routes that do not need it. The distinction is a property of how the plugin was written, not a flag at the call site: a plugin authored for application scope cannot simply be dropped into a route block. ## Where install calls live Because a module is just `fun Application.module()`, installation is ordinary Kotlin code and can be factored: ``` fun Application.module() { configureMonitoring() configureSerialization() configureRouting() } fun Application.configureMonitoring() { install(CallLogging) } ``` Those helper functions are extension functions on `Application` as well, so each one can install what it owns. This is the layout Ktor's own project generator produces, and it is the reason a Ktor app's setup stays readable as the plugin list grows. ## Common built-in plugins worth naming - **CORS** — browser cross-origin policy: which hosts, methods and headers are allowed. - **Compression** — response encoding, e.g. `gzip()` and `deflate()`, with conditions such as a minimum size. - **CachingHeaders** — attaches `Cache-Control` and related headers to outgoing content. - **CallLogging** — logs each call. - **ContentNegotiation** — request/response body conversion. - **StatusPages** — maps exceptions and status codes to responses. Being able to name a handful, and to say what each *plugs into*, is the tell that you understand the model rather than having copied a template. ## The one-sentence summary for an interview "`install()` attaches a plugin to a pipeline and runs its configuration once at startup; the plugin then hooks into every call flowing through that pipeline. Application scope covers the whole server, route scope narrows it to a subtree, and a plugin only supports route scope if it was written for it."

  • Why is code inside install(X) { ... } not executed per request?
    The lambda configures the plugin instance, and there is one instance per scope created during startup. Per-request work happens in the handlers the plugin registers on the pipeline, so putting request-specific logic in the config block runs it exactly once, against no call at all.
  • What is the practical reason to install a plugin on a route rather than the whole application?
    Scope of effect and cost. A policy that only makes sense for one subtree — extra auditing, a tighter body limit — should not run for every request. It also documents intent: reading the route block tells you what applies there, instead of a global list you must mentally filter.
  • What happens if a plugin's configuration is split across two install calls for the same plugin in one scope?
    It is rejected rather than merged. The pipeline holds a single configured instance under the plugin's key, so a second installation in the same scope is a startup error. The fix is to consolidate the configuration into one call.

saying these in an interview costs you the question

  • Thinks the install configuration block runs on every request
  • Expects two install calls for one plugin to merge their settings
  • Assumes plugins like CORS or Compression are enabled by default
  • Believes any plugin can be installed inside a route block
  • Describes install() as registering a route handler

context

open as a page

What are the phases of Ktor's ApplicationCallPipeline, and what determines the order interceptors run in?

level: middleimportance: must knowfreq 58%

basics

~20 s

Ktor's ApplicationCallPipeline has the phases Setup, Monitoring, Plugins, Call and Fallback, executed in that order. Within one phase, interceptors run in the order they were registered, so both the phase and the install order decide placement.

open as a page

How do you write a custom Ktor plugin, and when should it be route-scoped instead of application-scoped?

level: middleimportance: should knowfreq 46%

basics

~20 s

Build it with createApplicationPlugin, giving a name and handlers such as onCall, onCallReceive and onCallRespond; add a configuration class if it needs settings. Use createRouteScopedPlugin instead when the behaviour should apply to one route subtree rather than the whole server.

open as a page

A Ktor endpoint returns responses without the CORS headers you configured. How do you diagnose it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Check three things in order: that CORS is installed on a pipeline the request actually passes through, that the configuration matches the browser's origin, method and requested headers exactly, and that nothing responded and finished the pipeline before the plugin ran.

open as a page