What are the phases of Ktor's ApplicationCallPipeline, and what determines the order interceptors run in?
answer
- Five named phases, fixed order
- Observation wants to wrap everything
- Ties inside a phase are broken by registration order
- One call continues the rest of the pipeline
basics
~20 sKtor'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.
solid answer
~40 sA Ktor call is executed by a pipeline: an ordered list of **phases**, each holding interceptors. `ApplicationCallPipeline` defines `Setup` (prepare the call), `Monitoring` (tracing and logging, which want to wrap everything), `Plugins` (most plugin work), `Call` (routing and your handler) and `Fallback` (nothing responded — this is where a 404 comes from). Phases run in that fixed order; **within** a phase, interceptors execute in registration order, which for plugins means the order you called `install()`. An interceptor is a suspending block that can act before the rest of the pipeline, call `proceed()` to continue, and then act after it returns — so the structure is nested, not merely sequential. Placing a custom interceptor therefore means choosing the phase first, then, if it must run relative to another plugin, controlling install order.
code
kotlin · 12 linesfun Application.module() {
intercept(ApplicationCallPipeline.Monitoring) {
val start = System.nanoTime()
proceed()
val micros = (System.nanoTime() - start) / 1000
call.application.log.info("{} {} -> {}us", call.request.httpMethod.value, call.request.uri, micros)
}
routing {
get("/health") { call.respondText("OK") }
}
}go deeper
Recall that a Ktor call passes through a pipeline of named phases and that plugins hook into it rather than being called by your handler.
Name the phases in order, explain the phase-then-registration-order rule, and show that proceed() gives around-advice semantics rather than a linear filter chain.
Place interceptors deliberately — observation early so it wraps everything, manipulation in Plugins — and recognise ordering-sensitive interactions between response-modifying plugins.
Prefer encoded ordering over convention: when correctness depends on placement, require a custom phase so the guarantee survives refactors instead of living in one module's install sequence.
## The pipeline model Ktor does not process a request with a fixed chain of hard-coded steps. It builds an `ApplicationCall` and pushes it through a **pipeline**: an ordered collection of named phases, where each phase holds zero or more interceptors. Plugins do their work by registering interceptors in the phase appropriate to their job. `ApplicationCallPipeline` declares these phases, in execution order: - **Setup** — preparing the call before anything inspects it. - **Monitoring** — observation: logging, tracing, metrics. It runs early precisely so that an interceptor here wraps everything that follows and can measure it. - **Plugins** — where most plugin behaviour lives. - **Call** — routing and your route handler. - **Fallback** — reached when nothing has responded. This is why an unmatched path produces a 404 without you writing one. (The `Plugins` phase was called `Features` before Ktor 2.0, when the whole extension concept was renamed. Old blog posts and answers still use the old name.) ## Interception is nested, not linear An interceptor is a suspending lambda that receives the call. Crucially it can run code, then hand control onward, then run more code when control returns: ``` intercept(ApplicationCallPipeline.Monitoring) { val start = System.nanoTime() proceed() // run the rest of the pipeline val micros = (System.nanoTime() - start) / 1000 call.application.log.info("{} took {}us", call.request.uri, micros) } ``` `proceed()` executes the remainder of the pipeline and suspends until it finishes. That gives you the familiar around-advice shape. If an interceptor **does not** call `proceed()`, execution continues after the block returns — unless the interceptor calls `finish()`, which stops the pipeline immediately. Short-circuiting like that is exactly how a plugin can reject a call — responding and finishing — without the route handler ever running. Because the shape is nested, an interceptor in an *earlier* phase wraps everything in later phases. That is why observation belongs in `Monitoring`: it sees the whole call, including work done by plugins in `Plugins` and the handler in `Call`. ## What decides order Two things, in this priority: 1. **The phase.** Fixed and total. Anything in `Monitoring` runs before anything in `Plugins`, which runs before `Call`. No amount of install ordering changes that. 2. **Registration order within the phase.** Interceptors added to the same phase run in the order they were added. For plugins, that is the order of your `install()` calls in the module function. So when someone says "plugin order matters in Ktor," the accurate version is: it matters *among plugins that register in the same phase*. Two plugins in different phases are ordered by their phases regardless of how you wrote the installs. This has a very practical consequence for response-modifying plugins. If plugin A rewrites the response body and plugin B adds a header based on that body, their relative order changes the outcome — and you control it by install order when they share a phase. Compression and caching-header behaviour are the usual real-world examples: the observable result depends on which one sees the response first. ## Custom phases A pipeline can also be extended. You can define your own `PipelinePhase` and insert it relative to an existing one — before or after a named phase — then register interceptors there. This is the escape hatch when "same phase, controlled install order" is too blunt: it lets a plugin guarantee it runs after a specific phase without depending on the order in which someone happened to write the install calls. Plugins that need deterministic placement do exactly this internally. ## Routing is a pipeline too The `Call` phase is where routing runs, and routing has its own nested pipelines per route. That is how route-scoped plugins can exist at all: their interceptors are attached to a route's pipeline rather than the application's, so only calls matching that route pass through them. ## How to answer well Name the five phases in order, state the two-level ordering rule (phase first, then registration order within a phase), and demonstrate that you know `proceed()` makes interception nested rather than a one-shot filter. Add the placement heuristic — observation in `Monitoring` so it wraps everything, request/response manipulation in `Plugins`, handling in `Call` — and mention custom phases as the tool for deterministic ordering when install order is too fragile to rely on.
- What is the difference between not calling proceed() and calling finish() in a Ktor interceptor?Omitting proceed() only defers the rest of the pipeline until your block returns, after which it still runs. finish() stops the pipeline outright, so nothing downstream executes. To reject a call you respond and then finish; simply skipping proceed() will let the handler run anyway.
- Why do tracing and metrics interceptors belong in the Monitoring phase?Monitoring runs before Plugins and Call, so an interceptor there wraps the whole remainder of the call and can measure it end to end. Registering the same logic in a later phase would silently exclude everything earlier, understating latency and missing failures raised upstream.
- When is defining a custom PipelinePhase better than relying on install order?When placement must be guaranteed rather than conventional. Install order is a property of one module function and is easy to break during a refactor; a phase inserted before or after a named phase encodes the requirement in the plugin itself, so it holds no matter how callers arrange their installs.
saying these in an interview costs you the question
- Says plugins run strictly in install order regardless of phase
- Thinks skipping proceed() cancels the rest of the pipeline
- Describes interceptors as one-shot filters with no after-stage
- Claims the 404 for an unmatched path is produced by routing itself
- Uses the pre-2.0 name Features as the current phase name