Rocket Interview Questions and Answers
Last updated:
Check out 35 of the most common Rocket interview questions, then take an AI-powered practice interview
Q1What does the #[launch] attribute do, and when would you use #[rocket::main] instead?
BasicApplication Bootstrap
Answer
Rocket runs on Tokio, so every Rocket program needs an async runtime before the server can start. #[launch] is the shortcut: you annotate a function that returns a Rocket<Build> instance, and the macro writes main() for you, spins up the multi-threaded Tokio runtime, awaits launch(), and prints a readable error and exits with a non-zero code if ignition fails. It also unlocks the -> _ return type sugar, where the compiler infers Rocket<Build> so you do not have to spell it out. #[rocket::main] is the other option: it marks your own async fn main, and you are responsible for building the instance and awaiting .launch() yourself. Reach for it whenever you need real work around the server lifecycle, running sqlx migrations before binding the port, fetching secrets from AWS Secrets Manager or Infisical, or doing cleanup after launch() returns (it resolves only once the server has fully shut down, so code after the await runs during shutdown, not at startup).
A common mistake is reaching for #[tokio::main] with a hand-tuned runtime; that compiles, but you then have to call rocket::build().launch().await yourself and you lose the error reporting the macros give you for free. Also remember that #[launch] expects a synchronous function; if your setup needs .await, switch to #[rocket::main]. In Rocket 0.5 and later the builder starts with rocket::build(), which replaced 0.4's rocket::ignite(), and the instance moves through the Build, Ignite, and Orbit phases as fairings run.
#[macro_use] extern crate rocket;
#[get("/health")]
fn health() -> &'static str {
"ok"
}
// Option A: the macro writes main() for you
#[launch]
fn rocket() -> _ {
rocket::build().mount("/", routes![health])
}
// Option B: you own main, useful when setup needs .await
// #[rocket::main]
// async fn main() -> Result<(), rocket::Error> {
// run_migrations().await;
// let _rocket = rocket::build()
// .mount("/", routes![health])
// .launch()
// .await?;
// println!("server shut down cleanly");
// Ok(())
// }
Key Points
- #[launch] generates main(), starts Tokio, and reports ignition errors
- -> _ inference only works under #[launch]
- #[rocket::main] for async setup such as migrations or secret fetching
- launch().await resolves at shutdown, not at startup
- rocket::build() replaced rocket::ignite() in 0.5
Q2How do route attributes, routes![] and mount() fit together in Rocket?
BasicRouting
Answer
A Rocket route is a normal Rust function plus an attribute macro naming the method and path: #[get("/users")], #[post("/users", data = "<new_user>")], #[patch("/users/<id>")], and so on. The macro does not register anything by itself. It generates a hidden static Route value alongside your function, and routes![] collects those values into a Vec<Route>. mount() then attaches that vector at a base path, so mount("/api/v1", routes![list_users, create_user]) exposes /api/v1/users.
The separation matters in interviews because it explains three things people get wrong. First, forgetting to add a function to routes![] silently produces a 404 with no compiler error, since the function is still valid Rust. Second, you can mount the same routes at multiple base paths, which is how teams serve /v1 and /internal from one handler set.
Third, mount points compose with the uri! macro, so typed URI generation stays correct after you move a mount. Rocket checks the whole route table at ignition and refuses to launch if two routes could match the same request with the same rank, printing a colliding routes error that names both handlers. That launch-time check is a genuine advantage over frameworks that resolve conflicts by registration order at runtime.
Keep route functions thin: parse with guards, call a service function, return a responder. Handlers with database logic inline are hard to unit test because you can only reach them through a Client dispatch.
#[macro_use] extern crate rocket;
use rocket::serde::json::Json;
#[get("/users")]
fn list() -> Json<Vec<&'static str>> {
Json(vec!["asha", "ravi"])
}
#[post("/users", data = "<name>")]
fn create(name: String) -> String {
format!("created {}", name)
}
#[launch]
fn rocket() -> _ {
rocket::build()
.mount("/api/v1", routes![list, create])
.mount("/internal", routes![list])
}
Key Points
- Attribute macros generate a Route value; routes![] collects them
- mount(base, routes![...]) is what actually registers handlers
- Missing a function from routes![] gives a silent 404, not a compile error
- Rocket aborts launch on colliding routes instead of failing at runtime
Q3How do dynamic path segments work, and what is the difference between <id> and <path..>?
BasicRouting
Answer
A single angle-bracket segment like <id> matches exactly one path segment and is passed to the handler parameter of the same name. Rocket converts the raw string using the FromParam trait, which is implemented for the integer types, f32/f64, bool, String, &str, Option<T>, Result<T, E>, and (with the uuid feature) Uuid. If conversion fails, the route does not error, it forwards, and Rocket tries the next matching route by rank before finally running a catcher.
That forwarding behaviour is the single most-tested detail here, because people expect a 400 and get a 404. Multi-segment syntax <path..> matches the rest of the URI and binds to a type implementing FromSegments, usually PathBuf or rocket::fs::relative paths. PathBuf's implementation deliberately rejects segments containing .. or a leading dot or path separators, which is what stops directory traversal attacks on file-serving handlers; if you write your own FromSegments you inherit that responsibility.
You can implement FromParam for domain types to push validation to the edge, for example a PhoneNumber newtype that only accepts a ten-digit Indian mobile, so the handler body never sees an invalid value. Return Result rather than Option from a guard when you want the error preserved, and remember that <_> is the ignore syntax for a segment you must match but do not need.
use std::path::PathBuf;
use rocket::request::FromParam;
struct Mobile(String);
impl<'r> FromParam<'r> for Mobile {
type Error = &'r str;
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
let ok = param.len() == 10 && param.chars().all(|c| c.is_ascii_digit());
if ok { Ok(Mobile(param.to_string())) } else { Err(param) }
}
}
#[get("/users/<mobile>")]
fn by_mobile(mobile: Mobile) -> String {
format!("lookup {}", mobile.0)
}
#[get("/assets/<file..>")]
fn asset(file: PathBuf) -> String {
format!("serving {}", file.display())
}
Key Points
- <id> matches one segment via FromParam; failure forwards, not 400
- <path..> matches many segments via FromSegments, typically PathBuf
- PathBuf's FromSegments blocks .. traversal for you
- Custom FromParam impls move validation out of handler bodies
Q4How does Rocket bind query string parameters, and how do you make one optional?
BasicRouting
Answer
Query parameters are declared inside the route path after a question mark, exactly like path segments. #[get("/search?<q>&<page>")] binds q and page from the query string using the FromForm machinery rather than FromParam, which is why the rules differ slightly from path segments. A plain parameter is required by default in the sense that a missing value fails the parse, so declare it as Option<T> when it may be absent, and Rocket will pass None. Types with a default, notably bool, parse a bare flag.
You can also collect a whole struct with #[get("/search?<filters..>")] where filters is a type deriving FromForm, which is the clean way to handle five or six optional filters without a handler signature that runs off the screen. Rocket ignores extra query parameters it does not know about unless you opt into strict parsing with Strict<T>, which is useful for public APIs where you want to reject typos like ?limitt=10 rather than silently returning the default page size. A trailing <_> or <params..> lets you match and discard the rest.
Two production gotchas come up often. First, repeated keys such as ?tag=a&tag=b need a Vec<String> field, not a String, or only one value survives. Second, query parsing happens before your handler runs, so a malformed value forwards the request and you get a 404 from the default catcher unless you type the field as Result<T, Error> to capture the failure and turn it into a proper 422.
use rocket::form::FromForm;
#[derive(FromForm)]
struct Filters<'r> {
#[field(name = "min-ctc")]
min_ctc: Option<u32>,
city: Option<&'r str>,
#[field(default = 20)]
limit: usize,
tag: Vec<String>,
}
#[get("/jobs?<q>&<filters..>")]
fn search(q: Option<&str>, filters: Filters<'_>) -> String {
format!(
"q={:?} city={:?} limit={} tags={:?}",
q, filters.city, filters.limit, filters.tag
)
}
Key Points
- Query params use FromForm rules, not FromParam
- Option<T> for optional values, Vec<T> for repeated keys
- <filters..> binds a whole FromForm struct
- Strict<T> rejects unknown or malformed fields instead of ignoring them
Q5What is the Responder trait, and which return types can a Rocket handler use out of the box?
BasicResponders
Answer
Responder is the trait that converts a handler's return value into an HTTP response. Rocket implements it for a long list of standard types so most handlers never touch the trait directly. &'static str and String become 200 responses with text/plain; Vec<u8> and &[u8] become application/octet-stream; File and NamedFile stream from disk with a content type guessed from the extension; Option<T> becomes the inner response or a 404 when None; Result<T, E> uses T on Ok and the error's responder on Err; a bare () is 200 with an empty body; and Status alone produces just that status code. Wrappers add behaviour: status::Created gives 201 with a Location header, status::Custom(Status, T) overrides the code on any inner responder, status::NoContent is 204, content::RawJson and content::RawHtml override the content type on a string you already serialised, and Redirect::to() with the uri! macro produces a 303 or, with Redirect::permanent, a 301.
Tuples work too, so (Status::Accepted, Json(body)) is valid and reads well. The important mental model is that a responder is applied after all guards have run and it cannot fail in an interesting way: the trait method returns Result<Response, Status>, so the only failure mode is a status code, not a rich error body. That is why production APIs implement Responder on their own error enum rather than returning raw Status values, which is covered separately in the error-handling question.
use rocket::http::Status;
use rocket::response::{content, status, Redirect};
use rocket::serde::json::Json;
#[get("/plain")]
fn plain() -> &'static str { "hello" }
#[get("/maybe/<id>")]
fn maybe(id: u32) -> Option<String> {
if id == 1 { Some("found".into()) } else { None } // None becomes 404
}
#[post("/orders")]
fn create() -> status::Created<Json<&'static str>> {
status::Created::new("/orders/42").body(Json("created"))
}
#[get("/accepted")]
fn accepted() -> (Status, content::RawJson<&'static str>) {
(Status::Accepted, content::RawJson("{\"queued\":true}"))
}
#[get("/old")]
fn old() -> Redirect {
Redirect::permanent(uri!(plain))
}
Key Points
- Responder converts a return value into a Response
- Option gives 404 on None; Result delegates to the error's responder
- status::Created, status::Custom, content::RawJson, Redirect for control
- Tuples like (Status, Json<T>) are valid responders
Q6How do you accept and return JSON in Rocket, and what does the json feature flag change?
BasicJSON and Serialization
Answer
JSON support lives in rocket::serde::json and is gated behind the json feature, so your Cargo.toml needs rocket = { version = "0.5", features = ["json"] }. Without it, the import simply does not exist and you get an unresolved module error that trips up beginners. Json<T> plays two roles.
As a data guard, #[post("/users", data = "<user>")] with user: Json<CreateUser> reads the body, checks the content type is application/json, deserialises with serde, and forwards or fails if anything is wrong. As a responder, returning Json<T> serialises T and sets application/json. Rocket re-exports serde itself, and the derive macros expect #[serde(crate = "rocket::serde")] on your structs when you import Serialize and Deserialize from rocket::serde, which avoids version conflicts between your serde and Rocket's.
Three behaviours are worth knowing for interviews. First, the body size is capped by the json limit, which defaults to 1 MiB, and exceeding it produces 413 Payload Too Large rather than a parse error. Second, a malformed body produces 400 by default, while a body that parses as JSON but does not match the struct produces 422 Unprocessable Entity, so clients can distinguish syntax errors from schema errors.
Third, rocket::serde::json::Value exists for dynamic payloads, and the json! macro builds one inline, which is handy for error envelopes. For payloads you cannot fully model, prefer #[serde(deny_unknown_fields)] on the parts you do model so an attacker cannot smuggle extra keys past a permissive struct.
use rocket::serde::{Deserialize, Serialize, json::Json};
#[derive(Deserialize)]
#[serde(crate = "rocket::serde", deny_unknown_fields)]
struct CreateUser<'r> {
email: &'r str,
age: u8,
}
#[derive(Serialize)]
#[serde(crate = "rocket::serde")]
struct UserView {
id: u64,
email: String,
}
#[post("/users", data = "<body>")]
fn create(body: Json<CreateUser<'_>>) -> Json<UserView> {
Json(UserView { id: 1, email: body.email.to_string() })
}
Key Points
- Enable the json feature; rocket::serde::json::Json is both guard and responder
- Use #[serde(crate = "rocket::serde")] with Rocket's re-exported serde
- 413 on limit breach, 400 on malformed JSON, 422 on schema mismatch
- json! and Value cover dynamic payloads and error envelopes
Q7How does managed state work with .manage() and &State<T>?
BasicState Management
Answer
Rocket has no global registry and no dependency injection container. Shared application state is registered on the builder with .manage(value) and retrieved in a handler by taking a &State<T> parameter, where T is the exact type you managed. State<T> is a request guard, so it participates in the same machinery as everything else: Rocket looks up the type in a type-keyed map and injects a shared reference.
Because handlers can run concurrently on many Tokio worker threads, T must be Send + Sync + 'static, and any interior mutability has to be explicit. Use Arc for shared ownership across tasks, and for mutable state prefer rocket::tokio::sync::RwLock or Mutex over the std equivalents, because holding a std lock across an .await point blocks the worker thread and can deadlock under load. Atomics such as AtomicU64 are the right tool for counters.
Only one value per type can be managed, which is a deliberate design choice, so if you need two String settings, wrap each in its own newtype rather than managing two Strings, since the second .manage() call replaces the first. The best part of this design is the safety net: if a handler asks for &State<AppConfig> and you never called .manage() for AppConfig, Rocket detects the mismatch through its sentinel mechanism and refuses to launch with a clear message, instead of returning 500s in production. That launch-time guarantee is a favourite interview question because it shows whether the candidate has read past the routing chapter of the guide.
use std::sync::atomic::{AtomicU64, Ordering};
use rocket::State;
use rocket::tokio::sync::RwLock;
struct Hits(AtomicU64);
struct Cache(RwLock<Vec<String>>);
#[get("/hit")]
fn hit(hits: &State<Hits>) -> String {
let n = hits.0.fetch_add(1, Ordering::Relaxed) + 1;
format!("{} hits", n)
}
#[post("/cache/<item>")]
async fn push(item: String, cache: &State<Cache>) -> &'static str {
cache.0.write().await.push(item);
"ok"
}
#[launch]
fn rocket() -> _ {
rocket::build()
.manage(Hits(AtomicU64::new(0)))
.manage(Cache(RwLock::new(Vec::new())))
.mount("/", routes![hit, push])
}
Key Points
- manage() stores by concrete type; &State<T> retrieves it
- T must be Send + Sync + 'static; use newtypes to store two of the same type
- Prefer tokio::sync locks over std locks in async handlers
- Sentinels abort launch if a handler needs unmanaged state
Q8How does Rocket configuration work with Rocket.toml, profiles and ROCKET_ environment variables?
BasicConfiguration
Answer
Since 0.5 Rocket builds its configuration with Figment, a layered config library by the same author. The default provider reads, in increasing order of priority, Rocket's built-in defaults, then Rocket.toml (searched upward from the current directory), then any file named by ROCKET_CONFIG, then environment variables prefixed with ROCKET_. Later layers win, so ROCKET_PORT=9000 overrides whatever the file says, which is exactly what you want inside a container.
The file is organised into profiles. [default] applies everywhere, [global] overrides everything, and named profiles such as [debug] and [release] apply when the matching profile is selected. Rocket picks debug for cargo run and release for a release build, and you can select any profile explicitly with ROCKET_PROFILE=staging. Keys worth memorising: address and port for binding (set address = "0.0.0.0" or the container will only answer on loopback and your Kubernetes readiness probe will fail), workers for Tokio worker threads (defaults to the CPU count), max_blocking for the blocking pool ceiling, keep_alive in seconds, ident for the Server header, log_level, limits for body-size caps, temp_dir, and secret_key.
You can extend the same Figment with your own typed section and read it at ignition, which is the idiomatic alternative to sprinkling std::env::var calls through your services. Two production notes: Rocket prints its resolved config at startup, so read that block when a value seems ignored, and remember that profile names in TOML are case-insensitive but env variable names are uppercase by convention.
# Rocket.toml
[default]
address = "0.0.0.0"
port = 8000
workers = 8
keep_alive = 5
log_level = "normal"
[default.limits]
json = "512 KiB"
file = "5 MiB"
[release]
log_level = "critical"
secret_key = "generate-with-openssl-rand-base64-32"
[staging]
port = 9000
# Overrides at runtime
# ROCKET_PROFILE=staging ROCKET_PORT=9100 cargo run --release
Key Points
- Figment layering: defaults, Rocket.toml, ROCKET_CONFIG file, ROCKET_ env vars
- [default], [global] and named profiles; ROCKET_PROFILE selects one
- address = "0.0.0.0" is mandatory inside containers
- Extract your own typed config from the same figment at ignition
Q9What are catchers in Rocket and how do you register a custom 404 or 500 handler?
BasicError Handling
Answer
Catchers handle the responses Rocket generates when no route produced one: a 404 because nothing matched or every candidate forwarded, a 422 because a data guard rejected the body, a 500 from an internal error, or any status you produce yourself. You declare one with #[catch(404)] on a function and register it with .register(base, catchers![...]). The base path scopes the catcher, so .register("/api", catchers![api_not_found]) applies only under /api while .register("/", catchers![fallback]) applies everywhere else, and Rocket picks the most specific match.
A catcher function may take a &Request as a parameter, which is how you log the offending URI or read a header to decide between an HTML and a JSON error body, and #[catch(default)] catches every status you have not written a specific catcher for. The most common production requirement is making an API return JSON errors instead of Rocket's default HTML page, because a mobile client parsing HTML on a 404 is a support ticket waiting to happen. Two subtleties come up in interviews.
First, catchers cannot access managed state through the usual guard mechanism the way routes do; they receive only the Status and the Request, though you can reach the state through request.rocket().state::<T>(). Second, if your catcher itself panics or fails to build a response, Rocket falls back to its built-in catcher, so keep them trivially simple and free of I/O.
use rocket::{Request, catch, catchers};
use rocket::http::Status;
use rocket::serde::json::{json, Value};
#[catch(404)]
fn not_found(req: &Request<'_>) -> Value {
json!({ "error": "not_found", "path": req.uri().path().as_str() })
}
#[catch(422)]
fn unprocessable() -> Value {
json!({ "error": "validation_failed" })
}
#[catch(default)]
fn fallback(status: Status, _req: &Request<'_>) -> Value {
json!({ "error": "request_failed", "code": status.code })
}
#[launch]
fn rocket() -> _ {
rocket::build()
.register("/api", catchers![not_found, unprocessable])
.register("/", catchers![fallback])
}
Key Points
- #[catch(404)] plus register(base, catchers![...]); base scopes the catcher
- #[catch(default)] handles every uncovered status
- Catchers take &Request; use request.rocket().state::<T>() for state
- Return JSON from catchers on API mounts, not Rocket's HTML page
Q10How do you serve static files in Rocket, and what does the relative! macro solve?
BasicStatic Files
Answer
rocket::fs::FileServer is a ready-made handler you mount like any route set: .mount("/static", FileServer::from("static/")). It resolves the remaining path against the directory, refuses traversal outside the root, and sets a content type from the file extension. The problem with a plain relative path is that it resolves against the process working directory, not the crate, so the app works under cargo run from the project root and 404s the moment systemd or a Docker ENTRYPOINT starts it from somewhere else. rocket::fs::relative! fixes this by expanding at compile time to a path relative to the crate's Cargo.toml directory, so FileServer::from(relative!("static")) is stable regardless of the working directory.
FileServer::new(path, options) gives control over behaviour: Options::Index serves index.html for directory requests, Options::DotFiles allows hidden files (leave it off), Options::NormalizeDirs adds the trailing slash redirect so relative links inside HTML resolve correctly, and Options::Missing controls whether a nonexistent root aborts launch. For a single file, the NamedFile responder inside a normal route gives you full control, including adding a Cache-Control header through a tuple responder. In production behind a CDN or an nginx sidecar you would usually let the edge serve assets and keep Rocket for the API, but FileServer is the right answer for admin dashboards, generated reports, and single-binary deployments where shipping one executable plus a folder is the whole point of choosing Rust.
use rocket::fs::{FileServer, NamedFile, Options, relative};
use rocket::http::Header;
use std::path::Path;
#[get("/download/report")]
async fn report() -> Option<(Header<'static>, NamedFile)> {
let file = NamedFile::open(Path::new(relative!("data")).join("report.pdf"))
.await
.ok()?;
Some((Header::new("Cache-Control", "private, max-age=60"), file))
}
#[launch]
fn rocket() -> _ {
rocket::build()
.mount("/", routes![report])
.mount(
"/static",
FileServer::new(relative!("static"), Options::Index | Options::NormalizeDirs),
)
}
Key Points
- FileServer::from(path) mounts a directory; traversal is blocked by design
- relative! resolves against the crate root, not the working directory
- Options::Index, NormalizeDirs, DotFiles, Missing tune the behaviour
- NamedFile in a normal route when you need custom headers
Q11How do you handle HTML form submissions with #[derive(FromForm)] and Form<T>?
BasicForms
Answer
Derive FromForm on a struct, then take Form<T> as the data guard: #[post("/signup", data = "<form>")] with form: Form<Signup<'_>>. Rocket parses both application/x-www-form-urlencoded and multipart/form-data through the same derive, so a form with file inputs uses the identical shape with a TempFile field. Field names default to the Rust field name, and #[field(name = "user-name")] maps a hyphenated HTML name onto a snake_case field. #[field(name = uncased("Email"))] makes the match case-insensitive.
Validation is declarative through #[field(validate = ...)] using the built-ins in rocket::form::validate: len(8..64), range(18..=100), eq, neq, contains, ext for file extensions, and with for an arbitrary closure. Failures produce a 422 with per-field errors rather than a generic 400, and you can surface those errors by taking Result<Form<T>, Errors> instead of Form<T>, which is how server-rendered apps re-display a form with inline messages. Two behaviours matter in production.
By default parsing is lenient: unknown fields are ignored and missing fields with defaults are filled, whereas Strict<T> rejects anything unexpected, which you want for internal admin forms where a renamed input should fail loudly. And nested structures work with dotted names, so user.address.city in HTML maps onto nested FromForm structs, while a repeated tags[] input collects into Vec<String>. The form body size is capped by the form and data-form limits, which default to 32 KiB and 2 MiB respectively.
use rocket::form::{Form, Contextual, Strict};
#[derive(FromForm)]
struct Signup<'r> {
#[field(name = uncased("Email"))]
#[field(validate = contains('@'))]
email: &'r str,
#[field(validate = len(8..72))]
password: &'r str,
#[field(validate = range(18..=100))]
age: u8,
#[field(default = false)]
newsletter: bool,
}
#[post("/signup", data = "<form>")]
fn signup(form: Form<Strict<Signup<'_>>>) -> String {
format!("welcome {}", form.email)
}
// Contextual<T> keeps field-level errors for re-rendering a template
#[post("/signup-html", data = "<form>")]
fn signup_html(form: Form<Contextual<'_, Signup<'_>>>) -> String {
match &form.value {
Some(v) => format!("ok {}", v.email),
None => format!("{} errors", form.context.errors().count()),
}
}
Key Points
- Form<T> covers urlencoded and multipart through one derive
- #[field(name = ...)] maps HTML names; #[field(validate = ...)] validates inline
- Result<Form<T>, Errors> gives per-field messages for re-rendering
- Strict<T> rejects unknown fields; the form and data-form limits cap size
Q12What do the Rocket feature flags (json, secrets, tls, mtls, uuid) enable, and why did 0.5 stop requiring nightly Rust?
BasicTooling and Setup
Answer
Rocket ships lean and gates optional machinery behind Cargo features, so read the Cargo.toml before assuming an API exists. json enables rocket::serde::json with Json, Value and the json! macro. msgpack does the same for MessagePack. secrets enables private, encrypted cookies and makes secret_key meaningful. tls builds in rustls so Rocket can terminate HTTPS itself using the tls.certs and tls.key config keys. mtls adds client-certificate authentication and the Certificate request guard, which matters for bank and payment integrations in India where the counterparty mandates mutual TLS. uuid adds FromParam and FromForm implementations for Uuid so #[get("/orders/<id>")] with a Uuid parameter compiles. The nightly question is history worth knowing because interviewers use it to date a candidate's experience. Rocket 0.4 and earlier depended on unstable compiler features for its code generation, so the entire framework required a nightly toolchain, which was the main objection enterprises raised against adopting it.
Procedural macros stabilising in the Rust 2018 era let 0.5 rewrite code generation entirely on stable, and that release also moved the runtime to async Tokio. So the honest 2026 answer is that Rocket runs on the stable toolchain, needs a reasonably recent compiler rather than a pinned nightly, and the nightly requirement people remember belongs to a version that is long superseded. Pin your version in Cargo.toml, and pin the toolchain in rust-toolchain.toml so CI and local builds agree.
# Cargo.toml
[dependencies]
rocket = { version = "0.5", features = ["json", "secrets", "tls", "uuid"] }
rocket_db_pools = { version = "0.2", features = ["sqlx_postgres"] }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
rocket = { version = "0.5", features = ["json"] }
# rust-toolchain.toml keeps CI and local builds on the same compiler
# [toolchain]
# channel = "stable"
# components = ["rustfmt", "clippy"]
Key Points
- json, msgpack, secrets, tls, mtls, uuid are opt-in Cargo features
- secrets is required for private cookies and gives secret_key meaning
- mtls adds the Certificate guard for client-certificate auth
- 0.5 rebuilt codegen on stable Rust and moved the runtime to Tokio
Q13How does the CookieJar guard work, and when do you use add_private() instead of add()?
BasicCookies and Sessions
Answer
&CookieJar<'_> is a request guard, so any handler can take it and read or write cookies. Reads see what the browser sent; writes are collected on the jar and applied to the outgoing response automatically, so you never build a Set-Cookie header by hand. add() writes a plain cookie the client can read and edit. add_private() writes an encrypted and authenticated cookie: the value is sealed with the configured secret_key, and get_private() returns None if the value was tampered with, truncated, or signed by a different key. That authentication is why private cookies are the standard way to carry a session identifier without an extra database lookup on every request.
Private cookies require the secrets Cargo feature. With that feature on, Rocket generates a random secret_key in the debug profile and prints a warning, but it refuses to launch in the release profile unless you supply one, which is a good failure because a rotating key silently logs every user out on each deploy. Generate it with openssl rand -base64 32 and inject it as ROCKET_SECRET_KEY from your secret store rather than committing it.
Rocket 0.5 moved to the cookie 0.18 builder, so Cookie::build(("session", value)) takes a name and value tuple and remove_private("session") accepts a bare name. Always set http_only(true) so JavaScript cannot read the session, secure(true) in production so it never travels over plain HTTP, same_site(SameSite::Lax) as a baseline CSRF defence, and an explicit max_age, because a session cookie without one dies with the browser tab and confuses users on mobile. Note that private cookies are encrypted, not revoked: logging out clears the browser copy, so a stolen cookie stays valid until it expires unless you also track a server-side session version.
use rocket::http::{Cookie, CookieJar, SameSite};
use rocket::time::Duration;
#[post("/login")]
fn login(jar: &CookieJar<'_>) -> &'static str {
let cookie = Cookie::build(("session", "user-42"))
.path("/")
.secure(true)
.http_only(true)
.same_site(SameSite::Lax)
.max_age(Duration::hours(12));
jar.add_private(cookie);
"logged in"
}
#[get("/me")]
fn me(jar: &CookieJar<'_>) -> Option<String> {
jar.get_private("session").map(|c| c.value().to_string())
}
#[post("/logout")]
fn logout(jar: &CookieJar<'_>) -> &'static str {
jar.remove_private("session");
"bye"
}
Key Points
- &CookieJar<'_> is a guard; pending changes become Set-Cookie automatically
- add_private() encrypts and authenticates with secret_key; needs the secrets feature
- Release builds refuse to launch without secret_key set
- http_only, secure, SameSite and max_age are all opt-in, not defaults
Q14Why should you build links with the uri! macro instead of writing path strings?
BasicRouting
Answer
uri! is Rocket's typed URI builder. You pass it a route function and its arguments, uri!(job(id = 42)), and it expands at compile time into an Origin value. Three things happen that a format! string cannot give you.
First, the macro checks the route exists and that you supplied every dynamic segment and query parameter with the right type, so renaming a path from /jobs/<id> to /openings/<id> turns every stale link into a compile error rather than a 404 discovered by a user. Second, values are rendered through the UriDisplay trait, which percent-encodes them for the context they land in, so a query value containing a space, an ampersand, or a Devanagari character is escaped correctly instead of producing a broken URI. Third, mount points compose: uri!("/api/v1", search(q = "rust", page = Some(2))) prefixes the base you mounted at, so moving a mount does not require a search-and-replace across the codebase.
Optional query parameters accept Some(value) or the underscore placeholder to omit them. For absolute URLs, uri! can take a scheme and authority prefix, which is what you want when generating a verification link for an email rather than an in-page redirect. The result plugs directly into Redirect::to(), into a Location header, and into templates, since Origin implements Display.
The one thing to remember is that uri! resolves route functions by path in your crate, so a route in another module needs the module path, for example uri!(crate::jobs::job(id = 42)). Interviewers ask this to see whether a candidate uses Rocket's type system or treats it as an ordinary router with string paths.
use rocket::response::Redirect;
#[get("/jobs/<id>")]
fn job(id: u64) -> String {
format!("job {}", id)
}
#[get("/search?<q>&<page>")]
fn search(q: &str, page: Option<u32>) -> String {
format!("{} page {:?}", q, page)
}
#[get("/go")]
fn go() -> Redirect {
Redirect::to(uri!(job(id = 42)))
}
#[test]
fn uris_are_encoded() {
// mount prefix + encoded query value
let u = uri!("/api/v1", search(q = "rust dev", page = Some(2)));
assert_eq!(u.to_string(), "/api/v1/search?q=rust%20dev&page=2");
// omit an optional parameter
let u2 = uri!(search(q = "rust", page = _));
assert_eq!(u2.to_string(), "/search?q=rust");
}
Key Points
- uri! checks route existence and argument types at compile time
- UriDisplay percent-encodes values for the correct URI part
- Mount prefixes compose: uri!("/api/v1", route(...))
- Optional query args take Some(v) or the underscore placeholder
Q15Walk through implementing FromRequest, and explain why Outcome has Success, Error and Forward rather than just Ok and Err.
IntermediateRequest Guards
Answer
A request guard is any type implementing FromRequest. Rocket calls from_request for each guard-typed parameter, in declaration order, before the handler body runs, and the guard returns an Outcome. The three variants exist because HTTP routing has three distinct meanings, not two.
Success(value) injects the value. Error((Status, E)) stops the whole request and responds with that status, which is what you want for a present but invalid API key. Forward(Status) says this route declines the request, so Rocket moves on to the next route by rank and, if none match, runs a catcher with the forwarded status.
Forwarding is the mechanism behind optional authentication and role-based route pairs: a guard that forwards when the user is not an admin lets a lower-ranked route serve the ordinary user, all resolved at the routing layer instead of with if statements in a handler. In Rocket 0.5 the middle variant was renamed from Failure to Error and Forward now carries a Status, which is the most common compile break when porting 0.4 code. Two wrappers change guard semantics for free: Option<T> converts an Error or Forward into None, and Result<T, T::Error> hands you the error so the handler can decide.
Because from_request is async through #[rocket::async_trait], a guard can hit Redis or a database, but that runs on every matching request, so cache the result with req.local_cache() when several guards need the same lookup. Guards also implement Sentinel in many cases, which is how Rocket catches missing managed state at launch.
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome, Request};
pub struct ApiKey<'r>(&'r str);
#[derive(Debug)]
pub enum KeyError { Missing, Invalid }
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ApiKey<'r> {
type Error = KeyError;
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, KeyError> {
match req.headers().get_one("x-api-key") {
None => Outcome::Error((Status::Unauthorized, KeyError::Missing)),
Some(k) if k.starts_with("gs_") => Outcome::Success(ApiKey(k)),
Some(_) => Outcome::Error((Status::Forbidden, KeyError::Invalid)),
}
}
}
#[get("/private")]
fn private(key: ApiKey<'_>) -> String {
format!("authenticated as {}", key.0)
}
// Option<T> turns Error or Forward into None instead of a 401
#[get("/feed")]
fn feed(key: Option<ApiKey<'_>>) -> &'static str {
if key.is_some() { "personalised" } else { "public" }
}
Key Points
- Success injects, Error responds with a status, Forward tries the next route
- 0.5 renamed Outcome::Failure to Outcome::Error; Forward now carries a Status
- Option<T> makes a guard optional; Result<T, E> exposes the error
- Guards are async and run on every request, so cache expensive lookups
Q16How does Rocket rank routes, and what exactly triggers the colliding routes error at launch?
IntermediateRouting
Answer
Every route has an integer rank, and lower ranks are tried first. If you do not set one, Rocket assigns a negative default derived from how specific the route is: fully static paths with a static query beat partially dynamic ones, which beat wholly dynamic ones, and the defaults live in the negative range so any rank you write by hand (rank = 1, rank = 5) sorts after all of them. That default ordering is why /jobs/new and /jobs/<id> coexist without you doing anything: the static path is more specific, so it wins, and only unmatched requests fall through to the dynamic route.
Two routes collide when they could match the same request with the same rank, and Rocket detects this while building the route table at ignition. It aborts with a colliding routes error naming both handlers and their source locations, so the failure lands in CI rather than in production traffic. The fix is either to make one route more specific or to set an explicit rank on the fallback.
Ranking is also the standard pattern for authorisation fallbacks: an admin route with a guard that forwards, plus a lower-priority route at rank = 2 for ordinary users, plus a final rank = 3 route that returns 401. Catchers collide the same way and are checked at the same time. A detail interviewers like: because guards can forward, adding a guard changes which route serves a request at runtime, so a route table that looks unambiguous on paper can still fall through in ways you did not intend. Read the route table Rocket prints at startup, which lists every mount, path, rank and handler name in order.
use rocket::http::Status;
struct Admin;
struct User;
// rank defaults: static path wins over the dynamic one
#[get("/jobs/new")]
fn new_job_form() -> &'static str { "form" }
#[get("/jobs/<id>")]
fn job(id: u64) -> String { format!("job {}", id) }
// explicit ranks build an auth fallback chain
#[get("/reports")]
fn reports_admin(_a: Admin) -> &'static str { "full report" }
#[get("/reports", rank = 2)]
fn reports_user(_u: User) -> &'static str { "limited report" }
#[get("/reports", rank = 3)]
fn reports_anon() -> Status { Status::Unauthorized }
#[launch]
fn rocket() -> _ {
rocket::build().mount(
"/",
routes![new_job_form, job, reports_admin, reports_user, reports_anon],
)
}
Key Points
- Lower rank runs first; defaults are negative and based on specificity
- Static segments outrank dynamic ones automatically
- Collisions abort launch at ignition, not at request time
- rank = N plus forwarding guards is the idiomatic authorisation fallback
Q17What are fairings, which callbacks can they hook, and why is a fairing not middleware?
IntermediateFairings
Answer
A fairing is Rocket's lifecycle hook. You implement the Fairing trait, declare a Kind bitmask in info(), attach it with .attach(), and Rocket calls you at the phases you asked for: Ignite runs once while the instance moves from Build to Ignite, so it can read config, run migrations, or fail startup by returning Err; Liftoff runs once after the server binds, which is where you log the resolved address or register with service discovery; Request runs before routing and gets &mut Request plus the incoming Data; Response runs after the handler with &Request and &mut Response; and Shutdown runs when graceful shutdown begins. AdHoc gives you the same hooks as closures without defining a type, which is enough for header injection and one-off startup work.
The critical distinction from Express or Koa middleware is that a fairing cannot halt a request or short-circuit the response. on_request can inspect and rewrite the request, including the URI and headers, but it cannot return a response itself. So the Rocket way to implement authentication is a request guard, not a fairing; the common trick when you truly need fairing-level rejection is to rewrite the URI in on_request so it routes to a handler that returns the error. Fairings run in attach order for requests and the same order for responses, and because they see every request, keep them allocation-light. Typical production uses: a request id and timing pair (stash a start Instant with req.local_cache() in on_request, emit the duration in on_response), security headers, CORS headers, and a Liftoff hook that logs the effective configuration.
use std::time::Instant;
use rocket::fairing::{Fairing, Info, Kind, AdHoc};
use rocket::{Data, Request, Response};
use rocket::http::Header;
struct Timing;
#[rocket::async_trait]
impl Fairing for Timing {
fn info(&self) -> Info {
Info { name: "request timing", kind: Kind::Request | Kind::Response }
}
async fn on_request(&self, req: &mut Request<'_>, _: &mut Data<'_>) {
req.local_cache(|| Instant::now());
}
async fn on_response<'r>(&self, req: &'r Request<'_>, res: &mut Response<'r>) {
let start: &Instant = req.local_cache(|| Instant::now());
let ms = start.elapsed().as_millis();
res.set_header(Header::new("X-Response-Time-Ms", ms.to_string()));
}
}
#[launch]
fn rocket() -> _ {
rocket::build()
.attach(Timing)
.attach(AdHoc::on_response("hsts", |_, res| Box::pin(async move {
res.set_header(Header::new("Strict-Transport-Security", "max-age=63072000"));
})))
}
Key Points
- Kind::Ignite, Liftoff, Request, Response, Shutdown are the hook points
- on_ignite can abort startup by returning Err; on_liftoff cannot
- Fairings cannot terminate a request; use a request guard for auth
- AdHoc::on_response and AdHoc::try_on_ignite cover most needs without a type
Q18What is a data guard, and how do you stream a large upload without buffering it in memory?
IntermediateData Handling
Answer
A route may declare exactly one data guard, named in the attribute with data = "<name>", and its type implements FromData. Json<T>, Form<T>, String, Vec<u8>, TempFile and Data<'r> are the built-in choices. The important property is that reading the body is bounded: every built-in guard reads at most the configured limit for its kind, and the defaults are conservative (roughly 8 KiB for a String, 32 KiB for a urlencoded form, 2 MiB for a multipart form, 1 MiB for JSON and for a file).
Exceeding the limit does not silently truncate; it produces a 413. When you want to know whether truncation would have happened, wrap the type in Capped<T> and check is_complete(), which is the honest way to handle client uploads where you would rather return a clear error than a half-parsed record. For genuinely large payloads, do not take Vec<u8>.
Take TempFile, which streams the body to the configured temp_dir and gives you persist_to() to move it to final storage, or take Data<'r> and call open(limit) to get a DataStream you can copy into an AsyncWrite sink such as an S3 multipart upload. Both keep peak memory near constant regardless of body size. Set the matching limit in Rocket.toml under [default.limits], using the human-readable units Rocket parses ("10 MiB"), and remember your reverse proxy has its own cap: nginx defaults client_max_body_size to 1m, so an upload can be rejected before Rocket ever sees it. TempFile also exposes content_type() and a name, but never trust the client-supplied filename when building a storage path.
use rocket::data::{Data, ToByteUnit, Capped};
use rocket::fs::TempFile;
use rocket::tokio::io::AsyncWriteExt;
#[post("/resume", data = "<file>")]
async fn resume(mut file: Capped<TempFile<'_>>) -> Result<String, std::io::Error> {
if !file.is_complete() {
return Ok("file exceeded the configured limit".to_string());
}
let name = format!("/srv/uploads/{}.pdf", rocket::uuid::Uuid::new_v4());
file.persist_to(&name).await?;
Ok(name)
}
#[post("/raw", data = "<body>")]
async fn raw(body: Data<'_>) -> Result<String, std::io::Error> {
let mut sink = rocket::tokio::fs::File::create("/tmp/blob.bin").await?;
let written = body.open(50.mebibytes()).stream_to(&mut sink).await?;
sink.flush().await?;
Ok(format!("wrote {} bytes", written.written))
}
Key Points
- One data guard per route; FromData implementors include Json, Form, TempFile, Data
- Limits are per kind and produce 413, not truncation
- Capped<T> reports whether the body hit the cap
- TempFile::persist_to or Data::open(limit) keeps memory flat on big uploads
Q19How do you test Rocket routes with rocket::local::Client, and what is the difference between tracked and untracked clients?
IntermediateTesting
Answer
Rocket's local client dispatches requests directly into the routing machinery without opening a socket, so tests are fast, deterministic, and free of port conflicts in CI. There are two flavours: rocket::local::blocking::Client for ordinary #[test] functions, and rocket::local::asynchronous::Client for #[rocket::async_test] functions where the test itself needs to await something. You build one from the same Rocket<Build> instance your binary uses, which is the reason to factor construction into a rocket() function rather than burying it in main.
Client::tracked keeps a cookie jar across requests, so you can POST /login, then GET /me on the same client and the private session cookie travels along; that is how you test authenticated flows end to end. Client::untracked drops cookies between requests, which is what you want when asserting that an endpoint really requires a header and is not quietly passing because of leftover state. The response type gives you status(), headers(), into_string() and, with the json feature, into_json::<T>() so you can assert on a deserialised struct instead of matching on raw text.
Because the client short-circuits the network, it exercises guards, fairings, catchers and responders exactly as production does, but it does not exercise TLS, keep-alive, or your proxy. Two practical notes: construct the client once per test rather than per assertion, since ignition runs all on_ignite fairings including migrations, and point the tests at an isolated database through ROCKET_PROFILE=test with its own [test.databases.main] url so a cargo test run cannot touch a shared instance.
#[cfg(test)]
mod tests {
use super::rocket;
use rocket::local::blocking::Client;
use rocket::http::{Status, ContentType};
#[test]
fn health_is_ok() {
let client = Client::tracked(rocket()).expect("valid rocket instance");
let res = client.get("/health").dispatch();
assert_eq!(res.status(), Status::Ok);
assert_eq!(res.into_string().unwrap(), "ok");
}
#[test]
fn login_then_me_shares_the_session_cookie() {
let client = Client::tracked(rocket()).unwrap();
assert_eq!(client.post("/login").dispatch().status(), Status::Ok);
assert_eq!(client.get("/me").dispatch().status(), Status::Ok);
}
#[test]
fn bad_json_is_422() {
let client = Client::untracked(rocket()).unwrap();
let res = client
.post("/users")
.header(ContentType::JSON)
.body(r#"{"email": 12}"#)
.dispatch();
assert_eq!(res.status(), Status::UnprocessableEntity);
}
}
Key Points
- blocking::Client for #[test], asynchronous::Client for #[rocket::async_test]
- Client::tracked persists cookies across dispatches; untracked does not
- into_json::<T>() asserts on structs, not strings
- Ignition runs fairings and migrations, so build the client once per test
Q20How do you wire a Postgres pool with rocket_db_pools, and what does the Connection<Db> guard actually do per request?
IntermediateDatabases
Answer
rocket_db_pools is the async pooling crate for Rocket 0.5, replacing the old contrib database support. You derive Database on a newtype wrapping a pool type, tag it with #[database("main")] to bind it to a config section, and attach Db::init() as a fairing. Configuration lives under [default.databases.main] with url plus the tuning keys min_connections, max_connections, connect_timeout and idle_timeout, and because it is Figment, DATABASE_URL style secrets arrive as ROCKET_DATABASES='{main={url="postgres://..."}}' or through a profile-specific section.
In a handler you take Connection<Db>, which is a request guard: it checks out a connection from the pool when the guard runs and returns it to the pool when the guard is dropped at the end of the request. That has two consequences interviewers probe. First, holding the guard across a slow external HTTP call pins a pooled connection for the whole duration, so a burst of such requests exhausts max_connections and every other handler blocks on checkout until connect_timeout fires and returns 503.
Take the connection late and drop it early. Second, max_connections is a per-process number: three replicas at 20 each means 60 sessions against Postgres, which matters when your managed RDS instance caps connections. For Diesel, which is synchronous, use rocket_sync_db_pools instead and run queries inside conn.run(|c| ...).await so the blocking work moves off the async worker. Pool initialisation happens during ignition, so a bad URL aborts launch with a readable error rather than failing on the first request.
use rocket_db_pools::{Connection, Database, sqlx};
use rocket::serde::json::Json;
#[derive(Database)]
#[database("main")]
struct Db(sqlx::PgPool);
#[get("/candidates/<id>/email")]
async fn email(mut db: Connection<Db>, id: i64) -> Option<Json<String>> {
let row: (String,) = sqlx::query_as("SELECT email FROM candidates WHERE id = $1")
.bind(id)
.fetch_optional(&mut **db)
.await
.ok()??;
Some(Json(row.0))
}
#[launch]
fn rocket() -> _ {
rocket::build().attach(Db::init()).mount("/", routes![email])
}
// Rocket.toml
// [default.databases.main]
// url = "postgres://app:secret@localhost/goodspace"
// max_connections = 20
// connect_timeout = 5
Key Points
- #[derive(Database)] + #[database("name")] + attach(Db::init())
- [default.databases.name] url, max_connections, connect_timeout, idle_timeout
- Connection<Db> holds a pooled connection for the guard's lifetime
- Diesel needs rocket_sync_db_pools and conn.run() to avoid blocking Tokio
Q21What happens when a handler does blocking work, and how do spawn_blocking and max_blocking fix it?
IntermediateAsync and Concurrency
Answer
Rocket runs on the multi-threaded Tokio runtime with workers threads, defaulting to the CPU count. Those threads are cooperative: each one runs many futures, and a future is expected to reach an await point quickly. Any call that blocks the thread instead of yielding, a synchronous Diesel query, std::fs, reqwest::blocking, a bcrypt or argon2 hash, image resizing, or a std::sync::Mutex held across an await, takes an entire worker out of circulation.
On an 8-core box, eight concurrent blocking calls stall every other in-flight request, and the symptom in production is latency that collapses under load with flat CPU usage, which is exactly the pattern interviewers describe when they ask this. The fix is rocket::tokio::task::spawn_blocking, which moves the closure to a separate blocking thread pool and gives you a future to await. Rocket sizes that pool with the max_blocking config key, defaulting to 512, and it is also what backs conn.run() in rocket_sync_db_pools and the async file helpers.
Practical rules: use tokio::sync::Mutex and RwLock rather than the std versions whenever the lock is held across an await, keep any std lock scope tiny and never await inside it, prefer an async client library when one exists, and put CPU-bound work behind spawn_blocking even when it takes only a few milliseconds, because a few milliseconds times a thousand requests per second is a stalled worker. Tune workers for the async load and max_blocking for the blocking load separately; raising workers to hide a blocking call just moves the cliff. tokio-console makes the stall visible by showing tasks with long poll times.
use rocket::tokio::task::spawn_blocking;
use rocket::http::Status;
// WRONG: argon2 burns a Tokio worker for tens of milliseconds
#[post("/bad-hash", data = "<pw>")]
fn bad_hash(pw: String) -> String {
expensive_hash(&pw)
}
// RIGHT: move CPU-bound work to the blocking pool
#[post("/hash", data = "<pw>")]
async fn hash(pw: String) -> Result<String, Status> {
spawn_blocking(move || expensive_hash(&pw))
.await
.map_err(|_| Status::InternalServerError)
}
fn expensive_hash(pw: &str) -> String {
// stand-in for argon2 / bcrypt
format!("hashed:{}", pw.len())
}
// Rocket.toml
// [default]
// workers = 8
// max_blocking = 512
Key Points
- workers defaults to CPU count; blocking a worker blocks every task on it
- spawn_blocking moves the work to the pool sized by max_blocking (default 512)
- rocket_sync_db_pools conn.run() is spawn_blocking under the hood
- Never hold a std::sync lock across .await; use tokio::sync instead
Q22How do you map a domain error enum onto HTTP status codes with #[derive(Responder)] or a manual Responder impl?
IntermediateError Handling
Answer
Returning Result<T, Status> from every handler throws away the error detail, and returning Result<T, String> loses the status. The idiomatic Rocket answer is one application error enum that implements Responder, so handlers return Result<Json<T>, ApiError> and the mapping from domain failure to status code lives in exactly one place. The quick version is #[derive(Responder)]: annotate each variant with #[response(status = 404, content_type = "json")] and the first field becomes the body while any additional fields are set as headers.
That covers most cases with no boilerplate. The manual impl is worth it when you need a consistent JSON error envelope with a machine-readable code, a request id, and a message, or when you want to log server-side failures without leaking internals to the client: match on the variant, build the status and a serialised body, and respond with the same shape every time. Pair it with From implementations so the question mark operator converts sqlx::Error, serde_json::Error and your service errors automatically, which is what keeps handler bodies to three or four lines.
Two things to get right in production. Never let a 500 carry the Display output of a database error, because connection strings and table names end up in client logs; log the detail with the request id and return a generic message. And remember catchers still handle failures that happen before your handler runs, so a guard rejection or a malformed body produces Rocket's catcher output, not your enum, which is why the catcher JSON shape and the ApiError JSON shape should match.
use rocket::http::{ContentType, Status};
use rocket::request::Request;
use rocket::response::{self, Responder, Response};
use rocket::serde::json::json;
use std::io::Cursor;
#[derive(Debug)]
pub enum ApiError {
NotFound(&'static str),
Validation(String),
Internal(String),
}
impl<'r> Responder<'r, 'static> for ApiError {
fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> {
let (status, code, msg) = match self {
ApiError::NotFound(what) => (Status::NotFound, "not_found", what.to_string()),
ApiError::Validation(m) => (Status::UnprocessableEntity, "invalid", m),
ApiError::Internal(detail) => {
rocket::error!("internal error: {}", detail);
(Status::InternalServerError, "internal", "unexpected error".into())
}
};
let body = json!({ "error": code, "message": msg }).to_string();
Response::build()
.status(status)
.header(ContentType::JSON)
.sized_body(body.len(), Cursor::new(body))
.ok()
}
}
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self { ApiError::Internal(e.to_string()) }
}
Key Points
- One error enum implementing Responder centralises status mapping
- #[derive(Responder)] with #[response(status = ..., content_type = ...)] for the simple case
- From impls make the ? operator convert sqlx and serde errors
- Keep catcher output and error-enum output in the same JSON shape
Q23How do you push server-sent events from Rocket, and how does the Shutdown guard end those streams cleanly?
IntermediateStreaming
Answer
Rocket ships async response streams in rocket::response::stream: ByteStream for raw bytes, ReaderStream for anything implementing AsyncRead, TextStream for chunked text, and EventStream for server-sent events. Each has a generator-style macro so you can write straight-line code with yield instead of hand-rolling a Stream implementation. For SSE, the EventStream! macro yields Event values, and Rocket sets the text/event-stream content type, disables the usual response buffering, and writes each event as it is produced.
Event::data sends a plain payload, Event::json serialises a struct, and Event::data(...).event("name").id("42") sets the SSE fields a browser EventSource reads. The typical architecture pairs it with a tokio::sync::broadcast channel held in managed state: producers send, each connected client subscribes, and a Lagged error tells you that slow client fell behind the channel capacity, which you handle by skipping rather than disconnecting. Two production details separate people who have shipped this.
First, take the rocket::Shutdown guard and select! on it inside the loop, otherwise a graceful shutdown waits for streams that never end and your deploy hangs until the grace period expires. Second, call .heartbeat() so Rocket emits a comment line periodically; without it, proxies, load balancers and mobile networks silently drop an idle connection and the browser reconnects in a loop. Also remember SSE is one-way. If the client needs to send messages too, the answer in the 0.5 line is the rocket_ws crate, not EventStream.
use rocket::response::stream::{Event, EventStream};
use rocket::tokio::select;
use rocket::tokio::sync::broadcast::{channel, Sender, error::RecvError};
use rocket::tokio::time::Duration;
use rocket::{Shutdown, State};
use rocket::serde::{Serialize, Deserialize};
#[derive(Clone, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
struct Update { job_id: u64, stage: String }
#[post("/updates", data = "<msg>")]
fn publish(msg: rocket::serde::json::Json<Update>, tx: &State<Sender<Update>>) {
let _ = tx.send(msg.into_inner());
}
#[get("/updates")]
fn subscribe(tx: &State<Sender<Update>>, mut end: Shutdown) -> EventStream![] {
let mut rx = tx.subscribe();
EventStream! {
loop {
let msg = select! {
m = rx.recv() => match m {
Ok(m) => m,
Err(RecvError::Closed) => break,
Err(RecvError::Lagged(_)) => continue,
},
_ = &mut end => break,
};
yield Event::json(&msg);
}
}
.heartbeat(Duration::from_secs(15))
}
#[launch]
fn rocket() -> _ {
rocket::build().manage(channel::<Update>(1024).0).mount("/", routes![publish, subscribe])
}
Key Points
- EventStream!, TextStream!, ByteStream! macros yield values lazily
- Event::json / Event::data(...).event(...).id(...) map to SSE fields
- select! on the Shutdown guard or graceful shutdown stalls
- heartbeat() keeps proxies from killing idle connections
Q24How does rocket_dyn_templates work with Tera or Handlebars, and what changes between the debug and release profiles?
IntermediateTemplating
Answer
Server-rendered HTML lives in the separate rocket_dyn_templates crate, which was split out of the old contrib crate in 0.5. Add it with the tera or handlebars feature (you can enable both), attach Template::fairing(), and return Template::render("job", context) from a handler. The first argument is the template name without its extension, resolved against template_dir, which defaults to templates/ relative to the crate root.
Files carry a double extension so the engine is chosen by suffix: templates/job.html.tera or templates/job.html.hbs. The context! macro builds an ad-hoc context inline, and anything implementing Serialize works too, so you can pass your view struct directly. Template::custom(|engines| ...) runs at ignition and is where you register Tera filters, Handlebars helpers, or partials, for example an INR formatter that renders 1250000 as 12.5 LPA rather than doing that formatting in Rust for every field.
The profile difference matters in interviews: in the debug profile the fairing watches template_dir and reloads changed templates on the next request, so you edit HTML without restarting cargo run, while in release templates are compiled once at ignition and never re-read, which is why a template change on a running production binary appears to do nothing. Startup is also the only place a syntax error surfaces in release, and it aborts launch rather than failing at request time. Rocket does not escape by default in every engine the same way, so confirm autoescape settings before rendering user input, and prefer passing structured data over pre-built HTML strings.
use rocket_dyn_templates::{Template, context};
#[get("/jobs/<id>")]
fn job_page(id: u64) -> Template {
Template::render(
"job",
context! {
id: id,
title: "Rust Backend Engineer",
city: "Bengaluru",
ctc_lpa: 28,
},
)
}
#[launch]
fn rocket() -> _ {
rocket::build()
.attach(Template::custom(|engines| {
engines.tera.register_filter("inr", |v: &tera::Value, _: &_| {
let n = v.as_f64().unwrap_or(0.0) / 100000.0;
Ok(tera::Value::String(format!("{:.1} LPA", n)))
});
}))
.mount("/", routes![job_page])
}
// Rocket.toml
// [default]
// template_dir = "templates/"
Key Points
- rocket_dyn_templates with the tera or handlebars feature, plus Template::fairing()
- Names omit the extension; files are name.html.tera or name.html.hbs
- Template::custom registers filters and helpers at ignition
- Debug reloads templates on change; release compiles once at launch
Q25What problem does Request::local_cache solve, and when would you reach for local_cache_async?
IntermediateRequest Guards
Answer
Guards are resolved per parameter, and the same guard type can appear on several parameters or be nested inside other guards, so a naive authentication guard that decodes a JWT and loads the user from Postgres can run twice or three times for one request. local_cache fixes that: it stores a value in request-scoped storage keyed by its type, initialising it with your closure only the first time it is asked for, and hands out a shared reference on every later call within the same request. The storage is dropped when the request finishes, so there is no cross-request leakage and no manual cleanup. local_cache_async is the same idea for an initialiser that must await, which is the normal case for a guard that hits a database or Redis. The type-keyed part is the catch people miss: caching two different Strings in one request is impossible, because the second call returns the first value instead of running your closure.
Wrap each in a distinct newtype. The pattern also works across the fairing and guard boundary, which is how request ids and timings are implemented: an on_request fairing seeds a RequestId and an Instant, then any guard, handler, catcher or on_response fairing pulls the same values back out. A neat consequence is that a guard can return a borrowed &'r User backed by cache storage, avoiding a clone on every use. Interviewers often frame this as a performance question, but the correctness angle matters more: without it, an audit-log guard can write two rows for one request.
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome, Request};
pub struct User { pub id: i64, pub email: String }
async fn lookup(token: &str) -> Option<User> {
// one database round trip per request, not per guard
Some(User { id: 1, email: format!("{}@example.com", token) })
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for &'r User {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, ()> {
let cached: &Option<User> = req
.local_cache_async(async {
let token = req.headers().get_one("authorization")?;
lookup(token).await
})
.await;
match cached.as_ref() {
Some(user) => Outcome::Success(user),
None => Outcome::Error((Status::Unauthorized, ())),
}
}
}
// Both parameters resolve from one cached lookup
#[get("/profile")]
fn profile(user: &User, _also: &User) -> String {
user.email.clone()
}
Key Points
- Request-scoped, type-keyed memoisation; initialiser runs at most once per request
- local_cache_async for awaiting initialisers such as a DB lookup
- Two values of the same type collide; use newtypes
- Fairings and guards share the same storage, which is how request ids work
Q26Rocket 0.5 has no built-in CORS support. How do you implement it correctly for a browser SPA?
IntermediateSecurity
Answer
This surprises people coming from Express, where cors is a two-line import. Rocket has no CORS in core, so you either pull in a community crate or write a small response fairing, and interviewers like the question because getting CORS right requires understanding preflight rather than copying a snippet. Two pieces are needed.
First, the response headers: Access-Control-Allow-Origin echoing a specific allowed origin (never the wildcard when you also send Access-Control-Allow-Credentials, since browsers reject that combination), plus a Vary: Origin header so a shared cache does not serve one origin's response to another. Second, the preflight. Browsers send OPTIONS with Access-Control-Request-Method before any request that is not simple, meaning anything with a JSON content type or a custom header such as x-api-key.
Rocket has no route for OPTIONS by default, so that preflight hits the 404 catcher and the browser reports a CORS failure even though your fairing set the headers. The fix is a catch-all #[options("/<_..>")] route mounted at the root that returns nothing; the fairing then decorates it with Allow-Methods and Allow-Headers. Keep the allowed origin list in typed config rather than hard-coded, because staging, localhost:5173 for the Vite dev server, and production are different values, and a hard-coded wildcard is how internal APIs become publicly callable from any page. Set Access-Control-Max-Age so browsers cache the preflight instead of doubling your request count.
use rocket::fairing::{Fairing, Info, Kind};
use rocket::http::{Header, Method, Status};
use rocket::{Request, Response};
const ALLOWED: [&str; 2] = ["https://app.example.com", "http://localhost:5173"];
pub struct Cors;
#[rocket::async_trait]
impl Fairing for Cors {
fn info(&self) -> Info {
Info { name: "CORS", kind: Kind::Response }
}
async fn on_response<'r>(&self, req: &'r Request<'_>, res: &mut Response<'r>) {
if let Some(origin) = req.headers().get_one("Origin") {
if ALLOWED.contains(&origin) {
res.set_header(Header::new("Access-Control-Allow-Origin", origin.to_owned()));
res.set_header(Header::new("Access-Control-Allow-Credentials", "true"));
res.set_header(Header::new("Vary", "Origin"));
}
}
if req.method() == Method::Options {
res.set_status(Status::NoContent);
res.set_header(Header::new("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE"));
res.set_header(Header::new("Access-Control-Allow-Headers", "content-type, x-api-key"));
res.set_header(Header::new("Access-Control-Max-Age", "600"));
}
}
}
#[options("/<_..>")]
fn preflight() {}
#[launch]
fn rocket() -> _ {
rocket::build().attach(Cors).mount("/", routes![preflight])
}
Key Points
- No CORS in core; write a Kind::Response fairing or use a community crate
- Wildcard origin plus Allow-Credentials is rejected by browsers; echo the origin and set Vary
- Add a catch-all #[options("/<_..>")] route or preflights 404
- Allowed origins belong in config, and Max-Age cuts preflight traffic
Q27How do you add WebSockets to a Rocket 0.5 service, and how does rocket_ws differ from an SSE endpoint?
IntermediateStreaming
Answer
Rocket 0.5 core does not speak WebSocket, so the answer is the companion rocket_ws crate, which layers the protocol on top of tokio-tungstenite while keeping Rocket's routing and guards. You declare a normal #[get] route and take a ws::WebSocket request guard: taking that guard is what makes the route respond to an upgrade handshake instead of a plain GET. Because it is an ordinary route, everything else still applies, so your authentication guard, managed state, and path parameters work exactly as they do for HTTP, which is the main advantage over bolting a second server onto a different port.
There are two styles. ws.channel(|stream| ...) gives you the raw duplex stream to read and write in a closure, which suits request-response protocols and lets you hold connection-specific state. The ws::Stream! macro is the generator style, where you consume incoming messages with for await and yield outgoing ones, which reads better for echo and broadcast patterns. Choose WebSockets over SSE only when the client genuinely needs to send messages on the same connection; SSE is one-way but survives proxies more reliably, reconnects automatically in the browser, and needs no extra dependency.
Production notes: a WebSocket holds a connection and a task for its entire life, so cap concurrent connections and think about what that does to your pool and memory before promising realtime everywhere. Select on the Shutdown guard so deploys are not blocked by long-lived sockets, and be aware that most Indian mobile networks and corporate proxies will still drop an idle socket, so implement application-level pings.
use rocket_ws as ws;
use rocket::futures::{SinkExt, StreamExt};
// Guard-protected upgrade: auth still runs before the handshake
#[get("/ws/echo")]
fn echo(ws: ws::WebSocket, _user: &User) -> ws::Channel<'static> {
ws.channel(move |mut stream| Box::pin(async move {
while let Some(message) = stream.next().await {
let msg = message?;
if msg.is_text() || msg.is_binary() {
stream.send(msg).await?;
}
}
Ok(())
}))
}
// Generator style
#[get("/ws/stream")]
fn stream(ws: ws::WebSocket) -> ws::Stream!['static] {
ws::Stream! { ws =>
for await message in ws {
yield message?;
}
}
}
// Cargo.toml
// rocket_ws = "0.1"
Key Points
- rocket_ws provides the ws::WebSocket guard; core Rocket 0.5 has no WebSocket
- Taking the guard turns a normal #[get] route into an upgrade handler
- ws.channel(...) for duplex closures, ws::Stream! for the generator style
- SSE is simpler and proxy-friendly when the client only receives
Q28How do you terminate TLS inside Rocket, and what does enabling mutual TLS give you?
IntermediateSecurity
Answer
Enable the tls feature and Rocket links rustls, then configure it under [default.tls] with certs and key, both accepting a file path or the PEM bytes inline through Figment. That is all it takes for Rocket to serve HTTPS directly, which is genuinely useful for single-binary deployments, internal services that must be encrypted end to end, and local development against browser APIs that require a secure context. You can also tune ciphers and prefer_server_cipher_order.
In most cloud deployments you would still terminate TLS at an ALB, nginx or Cloudflare, because certificate renewal, HTTP/2 negotiation and OCSP stapling are handled better there, and then Rocket speaks plain HTTP inside the VPC. Mutual TLS is the more interesting half. Turn on the mtls feature, add [default.tls.mutual] with ca_certs pointing at the CA that signed your clients and mandatory = true to reject unauthenticated connections at the handshake, and you can then take a rocket::mtls::Certificate<'_> request guard.
That guard exposes the subject, issuer, serial and extensions of the presented client certificate, so authorisation becomes a property of the transport rather than a bearer token you have to rotate. This matters in India for payment and banking integrations, where NPCI and bank partners commonly mandate client-certificate authentication for settlement and reconciliation callbacks. With mandatory = false the guard forwards when no certificate was presented, which lets one service accept both token-authenticated public traffic and certificate-authenticated partner traffic on separate routes chosen by rank.
use rocket::mtls::Certificate;
#[get("/partner/settlement")]
fn settlement(cert: Certificate<'_>) -> String {
let cn = cert.subject().common_name().unwrap_or("unknown");
format!("authorised partner: {} (serial {})", cn, cert.serial())
}
// Rocket.toml
// [default.tls]
// certs = "/etc/ssl/fullchain.pem"
// key = "/etc/ssl/privkey.pem"
//
// [default.tls.mutual]
// ca_certs = "/etc/ssl/partner-ca.pem"
// mandatory = true
//
// Cargo.toml
// rocket = { version = "0.5", features = ["tls", "mtls"] }
Key Points
- tls feature plus [default.tls] certs and key gives rustls-backed HTTPS
- mtls feature plus [default.tls.mutual] ca_certs enables client certificates
- The Certificate guard exposes subject, issuer and serial for authorisation
- mandatory = false makes the guard forward instead of rejecting at handshake
Q29What are sentinels, how do they abort launch, and where does sentinel detection fail?
AdvancedReliability
Answer
Sentinels are Rocket's launch-time sanity check on the types your routes use. During code generation, the route macros record the concrete types that appear in guard positions and in the return type. At ignition, Rocket calls Sentinel::abort for each recorded type with the fully built Rocket<Ignite>, and if any returns true the launch is aborted with an error naming the offending type and route.
The built-in case everyone meets is State<T>: its sentinel checks whether a T was actually passed to manage(), so forgetting a .manage() call fails the process at startup rather than returning 500s at three in the morning. rocket_db_pools does the same for a pool whose config section is missing. You can implement Sentinel for your own types to encode invariants, for example a JwtAuth guard that refuses to launch when the signing key was never managed, or a MetricsRecorder that requires its fairing to be attached. The abort function has the whole ignited instance, so it can inspect state, config through rocket.figment(), the route table, and attached fairings.
The important limitation, and the part that separates a good answer, is that detection is syntactic. Rocket only sees the types written in the function signature, so a sentinel hidden behind a type alias that resolves through a generic parameter, inside a Box<dyn Responder>, or produced by a helper function called in the body is invisible, and you get a runtime failure instead. Rocket errs toward false negatives rather than blocking valid code, so treat sentinels as a strong safety net, not a proof.
use rocket::{Ignite, Rocket, Sentinel};
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome, Request};
pub struct JwtKeys { pub secret: String }
pub struct Claims { pub sub: String }
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Claims {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, ()> {
let keys = req.rocket().state::<JwtKeys>().expect("managed by sentinel");
match req.headers().get_one("authorization") {
Some(t) if !keys.secret.is_empty() => Outcome::Success(Claims { sub: t.into() }),
_ => Outcome::Error((Status::Unauthorized, ())),
}
}
}
impl Sentinel for Claims {
fn abort(rocket: &Rocket<Ignite>) -> bool {
if rocket.state::<JwtKeys>().is_none() {
rocket::error!("Claims guard needs JwtKeys; add .manage(JwtKeys { .. })");
return true; // true means refuse to launch
}
false
}
}
Key Points
- Sentinel::abort runs at ignition with the full Rocket<Ignite>
- State<T> sentinels catch a missing manage() call before traffic arrives
- You can implement Sentinel to require your own fairings or config
- Detection is syntactic: aliases, Box<dyn Trait> and helper returns escape it
Q30Explain Rocket's graceful shutdown: the grace and mercy periods, signals, and what breaks in Kubernetes.
AdvancedProduction Operations
Answer
Shutdown is configured under [default.shutdown]. ctrlc toggles the Ctrl-C handler, signals lists the Unix signals that trigger shutdown (term and hup are the usual pair), grace is how many seconds in-flight requests get to finish, mercy is how long Rocket then waits for connections and background I/O to wind down, and force decides whether Rocket terminates the runtime if tasks are still hanging after both timers. The sequence is: stop accepting new connections, let existing requests finish within grace, close idle keep-alive connections, wait mercy for the rest, then force if configured. Only after all that does the future returned by launch().await resolve, which is why code placed after the await is your cleanup hook: flushing metrics, closing a Kafka producer, or deregistering from service discovery.
You can also trigger shutdown from inside the application by taking the rocket::Shutdown guard and calling notify(), which is how an admin endpoint or a watchdog task stops the server, and the same guard is what long-lived SSE and WebSocket handlers select on so they do not outlive the grace period. The Kubernetes failure mode is the classic one and worth naming in an interview: terminationGracePeriodSeconds must exceed grace plus mercy, or the kubelet sends SIGKILL in the middle of Rocket's own wind-down and you drop requests anyway. Add a preStop sleep of a few seconds so the endpoints controller removes the pod from Service endpoints before the process stops accepting, otherwise the load balancer keeps sending traffic to a socket that is already closed and clients see connection resets rather than clean responses.
use rocket::Shutdown;
#[post("/admin/shutdown")]
fn stop(shutdown: Shutdown) -> &'static str {
shutdown.notify();
"shutting down"
}
#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
let _rocket = rocket::build()
.mount("/", routes![stop])
.launch()
.await?;
// runs during shutdown, after grace and mercy have elapsed
flush_metrics().await;
Ok(())
}
async fn flush_metrics() {}
// Rocket.toml
// [default.shutdown]
// ctrlc = true
// signals = ["term", "hup"]
// grace = 10
// mercy = 5
// force = true
//
// Kubernetes: terminationGracePeriodSeconds: 25
Key Points
- [default.shutdown] ctrlc, signals, grace, mercy, force control the sequence
- launch().await resolves only after shutdown completes; put cleanup after it
- The Shutdown guard triggers shutdown and lets streams exit their loops
- terminationGracePeriodSeconds must exceed grace + mercy, plus a preStop sleep
Q31What does the 'r lifetime on Request mean, and how do you write zero-copy guards and forms?
AdvancedType System
Answer
The 'r in FromRequest<'r>, Request<'r> and Responder<'r, 'o> is the request lifetime: the span during which the request, its parsed URI, its headers, and its request-local cache are alive. Rocket exposes it so guards and forms can borrow directly out of those buffers instead of allocating. That is why FromParam<'r> can produce a &'r str, why a FromForm struct can hold q: &'r str and avoid a String per field, and why a guard can hand back a &'r User that lives in the local cache.
On a hot endpoint parsing a dozen query fields, the difference between borrowing and cloning is a dozen heap allocations per request, which shows up in tail latency long before it shows up in throughput. Responder carries two lifetimes for a related reason: 'r is how long the response may borrow from the request, and 'o is the lifetime of the response body itself, which is why most owned responders are written as Responder<'r, 'static>. The rule that trips people is that you cannot borrow from a body you consumed inside your own guard, because the buffer is dropped when from_data returns.
The workaround Rocket itself uses is to store the owned bytes in request-local cache first, then hand out a &'r str pointing into that stored buffer, since the cache lives as long as the request. When lifetimes fight you in a handler signature, the honest escape hatch is to own the data with String, measure, and only reach for borrowing where it matters. Interviewers use this question to tell someone who writes Rust from someone who writes Rust with clone() everywhere.
use rocket::form::FromForm;
use rocket::request::{FromRequest, Outcome, Request};
use rocket::http::Status;
// Zero-copy form: fields borrow from the request's parsed buffers
#[derive(FromForm)]
struct Search<'r> {
q: &'r str,
city: Option<&'r str>,
}
#[get("/search?<s..>")]
fn search(s: Search<'_>) -> String {
format!("{} in {:?}", s.q, s.city)
}
// Zero-copy guard: the &'r str points into the request's header storage
pub struct TraceId<'r>(pub &'r str);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for TraceId<'r> {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, ()> {
match req.headers().get_one("x-trace-id") {
Some(id) => Outcome::Success(TraceId(id)),
None => Outcome::Error((Status::BadRequest, ())),
}
}
}
Key Points
- 'r is the request lifetime covering URI, headers and local cache
- &'r str fields in FromForm and FromParam avoid per-field allocations
- Responder<'r, 'o>: 'r borrows from the request, 'o is the body lifetime
- Borrow from request-local cache when you need data outliving your guard
Q32You inherited a Rocket 0.4 service on nightly Rust. What actually changes when you migrate it to 0.5?
AdvancedMigration
Answer
This is a real task in Indian teams that adopted Rust early, and the answer should be a plan, not a list of renames. The runtime change is the big one: 0.4 was synchronous, so every handler ran on a thread from a pool and blocking calls were fine. 0.5 is async on Tokio, so those same handlers must become async fn where they do I/O, and any synchronous database or HTTP call left in place now stalls a worker instead of just occupying a thread. Budget most of the effort here, not on syntax.
The mechanical changes: rocket::ignite() becomes rocket::build(); #[launch] and #[rocket::main] replace hand-written mains; the contrib crate is gone, split into rocket_dyn_templates, rocket_db_pools and rocket_sync_db_pools; rocket_contrib::json::Json moves to rocket::serde::json::Json behind the json feature, and derived structs need #[serde(crate = "rocket::serde")]; Outcome::Failure is renamed Outcome::Error and Outcome::Forward now carries a Status; catchers take &Request and support #[catch(default)]; configuration moves from the old Rocket.toml semantics and ROCKET_ENV to Figment profiles with ROCKET_PROFILE, with limits and secret_key handled differently; and the toolchain requirement drops from nightly to stable, so you delete the nightly pin from rust-toolchain.toml and your CI images get simpler. Sequence it as: pin dependencies and get it compiling on stable 0.5 with handlers still synchronous inside spawn_blocking, then port the data layer to an async driver route by route, then delete the blocking shims. Keep the local Client test suite green at every step, because that suite is the only cheap proof that guard and catcher behaviour did not shift.
// Rocket 0.4 (nightly, synchronous)
// #![feature(proc_macro_hygiene, decl_macro)]
// use rocket_contrib::json::Json;
//
// #[get("/users/<id>")]
// fn user(conn: DbConn, id: i64) -> Json<User> {
// Json(users::table.find(id).first(&*conn).unwrap())
// }
//
// fn main() {
// rocket::ignite().mount("/", routes![user]).launch();
// }
// Rocket 0.5 (stable, async)
use rocket::serde::json::Json;
use rocket_db_pools::{Connection, Database, sqlx};
#[derive(Database)]
#[database("main")]
struct Db(sqlx::PgPool);
#[get("/users/<id>")]
async fn user(mut db: Connection<Db>, id: i64) -> Option<Json<String>> {
let row: (String,) = sqlx::query_as("SELECT email FROM users WHERE id = $1")
.bind(id)
.fetch_optional(&mut **db)
.await
.ok()??;
Some(Json(row.0))
}
#[launch]
fn rocket() -> _ {
rocket::build().attach(Db::init()).mount("/", routes![user])
}
Key Points
- Async Tokio runtime is the substantive change; renames are the easy part
- ignite() to build(), contrib split into rocket_dyn_templates and the db pool crates
- Outcome::Failure to Outcome::Error; Forward now carries a Status
- Config moves to Figment profiles; nightly pin can be removed
Q33Rocket benchmarks slightly behind Axum and Actix Web. Where does the overhead come from and what would you actually tune?
AdvancedPerformance
Answer
Be honest first: in synthetic hello-world benchmarks Rocket usually sits behind Axum and Actix Web, though all three are in the same order of magnitude and far above typical interpreted stacks. The overhead is structural, not sloppy. Rocket resolves guards through a type-keyed lookup per request, walks a ranked route table doing matching and possible forwarding, allocates for its request-local cache, and its Responder path builds a Response object rather than writing bytes straight to the socket.
Axum leans harder on the tower ecosystem and monomorphises more of that work away. In practice a real handler that touches Postgres spends hundreds of microseconds in the database and tens of microseconds in the framework, so the framework choice rarely determines your p99. What to tune, in order of payoff: build with a real release profile, since a debug build is often several times slower and is the single most common cause of a bad self-run benchmark; set lto = "thin" and codegen-units = 1 for another few percent; size workers to the CPU count and max_blocking to your blocking workload separately; raise keep_alive if clients reconnect often, or set it to 0 when a load balancer is doing the pooling; and remove blocking calls from async handlers, which is worth more than every other item combined.
Do not set panic = "abort" in release: Rocket catches a panicking handler and returns 500, and abort turns one bad request into a dead process. Measure with oha or wrk against a release binary, profile with cargo flamegraph, and use tokio-console to find tasks with long poll times before blaming the framework.
# Cargo.toml
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
# panic = "abort" # do NOT: Rocket turns handler panics into 500s
# Rocket.toml
[default]
workers = 8 # async worker threads, default = CPU count
max_blocking = 512 # spawn_blocking pool ceiling
keep_alive = 5 # seconds; 0 disables keep-alive
log_level = "critical"
[default.limits]
json = "512 KiB"
# Measure a release build, never `cargo run`
# cargo build --release --locked
# ./target/release/api &
# oha -z 30s -c 200 http://127.0.0.1:8000/health
# cargo flamegraph --release --bin api
Key Points
- Overhead comes from guard resolution, ranked routing and Response construction
- Release profile plus lto and codegen-units before any micro-optimisation
- Tune workers, max_blocking and keep_alive to the real workload
- panic = "abort" kills the process where Rocket would have returned 500
Q34How do you get structured logs and OpenTelemetry traces out of a Rocket service, and what conflicts with Rocket's own logger?
AdvancedObservability
Answer
Rocket 0.5 installs its own logger behind the log facade and controls verbosity with the log_level key (off, critical, normal, debug), which is fine for local development and useless for production aggregation because the output is human-formatted, not JSON. The first step is therefore to set log_level = "off" in your release profile and install your own tracing_subscriber, otherwise you get two loggers fighting and duplicate or swallowed lines depending on initialisation order. With the subscriber owning output, add tracing-opentelemetry and an OTLP exporter and you can ship spans to SigNoz, Jaeger or any collector.
The Rocket-specific part is that fairings cannot wrap a handler, since there is no around hook, so you cannot open a span in on_request and close it in on_response with the handler nested inside it in the obvious way. The practical pattern is: an on_request fairing generates or reads a request id (honouring an incoming x-request-id or traceparent from your gateway) and stashes it plus an Instant in local_cache; service functions carry #[tracing::instrument] so the interesting work is properly spanned; and an on_response fairing emits one structured event with method, path, status, duration and the request id, and echoes the id back in a header. Also propagate the id into your database and HTTP clients so a slow query links to the request that caused it. Note that a panicking handler is caught by Rocket and logged, so make sure your subscriber captures those, and remember client IP comes from client_ip() honouring the ip_header key, not from remote(), when you are behind a proxy.
use std::time::Instant;
use rocket::fairing::AdHoc;
use rocket::http::Header;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
struct ReqId(String);
#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
tracing_subscriber::registry()
.with(EnvFilter::from_default_env())
.with(tracing_subscriber::fmt::layer().json())
.init();
let _ = rocket::build()
.attach(AdHoc::on_request("req-id", |req, _| Box::pin(async move {
let id = req
.headers()
.get_one("x-request-id")
.map(str::to_owned)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
req.local_cache(|| ReqId(id));
req.local_cache(|| Instant::now());
})))
.attach(AdHoc::on_response("access-log", |req, res| Box::pin(async move {
let id = &req.local_cache(|| ReqId(String::new())).0;
let start = req.local_cache(|| Instant::now());
tracing::info!(
request_id = %id,
method = %req.method(),
path = %req.uri().path(),
status = res.status().code,
duration_ms = start.elapsed().as_millis() as u64,
client_ip = ?req.client_ip(),
"request completed"
);
res.set_header(Header::new("x-request-id", id.clone()));
})))
.launch()
.await?;
Ok(())
}
Key Points
- Set log_level = "off" before installing tracing_subscriber or the two loggers collide
- No around hook: pair an on_request fairing with an on_response fairing via local_cache
- #[tracing::instrument] on service functions is where the useful spans come from
- Honour incoming traceparent or x-request-id and echo it back on the response
Q35How would you containerise and deploy a Rocket service, and why does client_ip() return the load balancer's address?
AdvancedProduction Operations
Answer
Rocket compiles to a single static-ish binary, which makes deployment simple if you get four things right. Build in a multi-stage Dockerfile: a rust builder stage running cargo build --release --locked, then copy only the binary into a minimal runtime image such as distroless cc or debian-slim. Do not ship the toolchain, and use cargo-chef or a cached dependency layer, because a cold Rust build in CI is minutes, not seconds.
Second, set ROCKET_ADDRESS=0.0.0.0 as an environment variable. The default binds to 127.0.0.1, so the container starts, logs happily, and refuses every connection from outside, which is the single most common first-deploy failure and a favourite interview trap. Third, copy anything the binary reads at runtime: Rocket.toml, the templates directory if you use rocket_dyn_templates, and any FileServer root, and prefer relative! so paths resolve against the crate rather than the container WORKDIR.
Set ROCKET_PROFILE=release explicitly, since profile selection follows the build profile and you want no ambiguity. Fourth, expose a cheap /health route with no database call for the readiness probe, and a separate deeper check for alerting. On client IP: behind an ALB or nginx, the TCP peer is the proxy, so Request::remote() gives you the proxy address.
Rocket reads the header named by the ip_header config key, which defaults to X-Real-IP, and client_ip() returns that value when present. If your proxy only sets X-Forwarded-For, either configure it to set X-Real-IP or point ip_header at the forwarded header, and never trust it on a port reachable from the internet directly, because a client can forge it.
# syntax=docker/dockerfile:1
FROM rust:1-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release --locked
FROM gcr.io/distroless/cc-debian12
WORKDIR /app
COPY --from=build /app/target/release/api /app/api
COPY Rocket.toml ./
COPY templates ./templates
COPY static ./static
ENV ROCKET_ADDRESS=0.0.0.0 \
ROCKET_PORT=8000 \
ROCKET_PROFILE=release \
ROCKET_LOG_LEVEL=off
EXPOSE 8000
CMD ["/app/api"]
# Rocket.toml
# [default]
# ip_header = "X-Forwarded-For" # only when the edge is trusted
# proxy_proto_header = "X-Forwarded-Proto"
Key Points
- Multi-stage build, distroless runtime, cargo-chef for dependency caching
- ROCKET_ADDRESS=0.0.0.0 or the container answers only on loopback
- Copy Rocket.toml, templates and static roots; use relative! for paths
- client_ip() reads the ip_header key (default X-Real-IP); remote() is the proxy
Frequently Asked Questions
What salary can a Rocket developer expect in India in 2026?
Rocket is not hired for on its own; it is hired as part of a Rust backend role, and Rust pays a premium in India because supply is thin. Freshers who can show real Rust work land roughly ₹8-14 LPA, which is already above the median for a general backend fresher. Two to five years of production Rust puts you in the ₹15-28 LPA band, and senior engineers who own async services, pooling, and performance work regularly see ₹30-45 LPA at product companies. The ₹12-32 LPA band on this page reflects the middle of that spread. Payments and fintech (Juspay), developer tooling (Hasura, DeepSource), infrastructure and edge companies, and blockchain teams pay at the top of it, while service companies rarely staff Rust at all. Remote roles with US or European companies hiring into India go higher still, though those loops test systems design and Rust internals harder than framework specifics.
How long should I prepare for a Rocket interview if I already know Rust?
If you are comfortable with ownership, lifetimes, traits and async/await, two to three focused weeks is enough. Week one: build a small CRUD service with routing, guards, JSON, managed state, catchers and Rocket.toml profiles, and write local Client tests for every endpoint as you go. Week two: add a Postgres pool with rocket_db_pools, a custom error responder, a fairing pair for request ids and timing, and graceful shutdown, then deliberately break things (drop a manage() call, add a colliding route, put a blocking call in a handler) so you have seen the actual error messages. Week three: read the guard, fairing and configuration chapters of the official guide end to end, plus the 0.5 release notes. If you do not know Rust yet, the honest number is three to six months before a Rocket interview is winnable, because every hard question in these loops is a Rust question wearing a web framework costume.
What is the difference between what freshers and experienced candidates are asked?
Freshers are asked to make things work: mount routes, parse a JSON body, return the right status code, read a path parameter, explain what #[launch] does, and write a handler live without fighting the borrow checker for ten minutes. Getting a small endpoint compiling and tested under time pressure is most of the bar. Experienced candidates are asked why things break: why a synchronous Diesel call collapses throughput under load, why a route returns 404 instead of 400, how connection pool sizing interacts with replica count, what happens to in-flight requests during a deploy, how they instrumented a service and what the traces showed. The 0.4 to 0.5 migration is a favourite for anyone claiming several years with Rocket, because the answer reveals whether they lived through the async transition or only read about it. Senior loops also include a design round where Rocket is incidental and the real subject is service boundaries and failure handling.
Is Rocket worth learning in 2026, or has Axum taken over?
Axum has the larger share of new Rust web projects, mostly because it composes with the tower middleware ecosystem and sits close to hyper. That is a real trend and pretending otherwise in an interview reads badly. Rocket remains worth learning for two reasons. First, existing services: teams that adopted Rust in the 0.4 and 0.5 era still run Rocket in production and hire for it, and maintenance work is real work. Second, the ideas transfer completely. Guards, ranked routing, typed URIs, launch-time validation through sentinels, and Figment configuration teach you how to encode HTTP invariants in the type system, and that thinking makes you better in Axum too. Practically, learn Rust properly, build something in Rocket, then port it to Axum. Being able to compare the two concretely is more impressive in an interview than deep loyalty to either.
How does Rocket compare to Actix Web and Axum for someone choosing one to learn first?
Actix Web is the throughput leader in most benchmarks and has the largest feature surface, including built-in WebSocket support, but its actor heritage and heavier use of generics make error messages harder for a newcomer. Axum is the current default choice: minimal, tower-based middleware, extractor-driven handlers, and the largest volume of recent tutorials and job postings. Rocket is the most opinionated and the friendliest to read, with batteries such as forms, templating, typed URIs and launch-time checks included, at the cost of a little raw speed and a smaller middleware ecosystem. For a first Rust web framework, Rocket teaches the concepts with the least ceremony, and its compile-time safety net catches mistakes that would be runtime bugs elsewhere. For maximum hiring surface in India today, Axum is the safer single bet. Knowing one well makes the other a weekend of reading, so pick by the job you are targeting rather than by benchmark charts.
I have no Rust job. How do I get credible Rocket experience for interviews?
Ship one service that does something real and can be talked about for twenty minutes. A URL shortener is too small; a job board API with authentication guards, Postgres through rocket_db_pools with sensible pool sizing, a custom error responder, JSON catchers, an SSE endpoint for live updates, request-id tracing, and a Docker image that actually runs behind nginx covers almost every question in this page. Put load through it with oha, capture the numbers before and after moving a blocking call to spawn_blocking, and write those numbers in the README. That single before-and-after measurement is the most convincing thing a self-taught candidate can bring, because it proves you understand the async model rather than having memorised it. Add a local Client test suite so you can answer testing questions from your own code, and contribute a documentation fix or small patch to a Rust crate you use so there is a public commit with your name on it.
Introduction
Rocket is the Rust web framework that trades a little raw throughput for a lot of compile-time safety and readable code. Its whole personality comes from procedural macros: you write #[get("/users/<id>")] above a plain function, and the macro generates the type-checked plumbing that parses the path segment, runs your request guards, and converts the return value into an HTTP response. Version 0.5 was the release that made Rocket a serious production option, it moved to async Tokio internals, dropped the nightly-only requirement that defined the 0.4 era, replaced the hand-rolled config parser with Figment, and split the old contrib crate into focused crates for databases and templates.
Interviewers for Rocket roles rarely ask trivia about the macro syntax. They probe the parts where Rust and HTTP collide: how FromRequest guards forward instead of failing, why Outcome has three variants rather than two, what the 'r lifetime on Request actually borrows, when a synchronous Diesel query will stall a Tokio worker thread, and how sentinels abort launch when you forget to call manage() for a State type. Indian teams that hire for this stack tend to be payments, developer tooling, and infrastructure companies (Juspay, Hasura, DeepSource, Cloudflare and blockchain teams among them), and their loops usually include a live coding round on top of the framework questions.
This guide covers 35 Rocket interview questions asked in 2026, ordered from fundamentals to the topics that decide senior offers. Every technical answer names the real API, config key, or error message involved, and most carry a working Rust snippet you can paste into a scratch project. Work through the basic section to lock down routing, responders, state and configuration, then spend your real preparation time on guards, fairings, database pooling, blocking-versus-async behaviour, graceful shutdown, and the 0.4 to 0.5 migration story, because those are the questions that separate a Rust hobbyist from someone a team will trust with production traffic.
Ready to practice Rocket interviews?
Don't just read, practice these Rocket questions live with an AI interviewer that asks follow-ups and scores your answers.