Every Spring Boot application begins with the same unremarkable line. You have almost certainly typed it without thinking about it:
@SpringBootApplication
public class StoreApplication {
public static void main(String[] args) {
SpringApplication.run(StoreApplication.class, args);
}
}
That single call is the startup. There is no application server to install first, no WAR file to deploy, no web.xml to configure. You run a plain Java main method, and a second or two later an HTTP server is listening on port 8080. This post is about what happens inside that second — the ordered sequence SpringApplication.run walks through, and exactly where the web server, running inside your own process, slots into it.
One word carries the whole story, so let me define it before using it. A container is just an object whose job is to create your other objects, hold them, wire them together, and manage their lifetimes. In Spring, the objects it manages are called beans — a bean is nothing more exotic than an object the container built and owns instead of you calling new yourself. The container is also called the application context. When you hear "context," picture that one big object holding every bean your app needs. Everything below is the story of how run builds that context and then hands control to it.
The one call, unpacked
SpringApplication.run(StoreApplication.class, args) is a static convenience method. Under the hood it does two separate things, and it helps to see them apart:
// what the static helper expands to
SpringApplication app = new SpringApplication(StoreApplication.class);
ConfigurableApplicationContext context = app.run(args);
First it constructs a SpringApplication object. Then it runs it. The constructor makes a few decisions up front; the run call executes the actual boot sequence. We will take them in that order.
Step 1 — the constructor decides what kind of app this is
Before any bean exists, the SpringApplication constructor asks one important question: what type of application am I? It answers by looking at what is on the classpath — the set of libraries your build pulled in.
The result is a value called the WebApplicationType, and it has three possibilities:
-
SERVLET— a servlet-based web class (Spring MVC'sDispatcherServlet) is on the classpath, so this is a traditional web app. This is the common case. -
REACTIVE— only the WebFlux reactive stack is present, no servlet stack. -
NONE— no web libraries at all, so this is a plain application that will run some logic and exit.
Nobody sets this flag by hand. The presence of spring-boot-starter-web in your build is what makes it SERVLET. This detection matters enormously, because it decides which context to build in Step 3 and whether a web server starts at all. A batch job with no web starter gets NONE, boots the same way, runs its work, and shuts down — no port is ever opened.
The constructor also loads a couple of lists of helper objects (initializers and listeners) from configuration files bundled in the Spring jars, but you can treat those as pre-wiring for now. The headline decision is the application type.
Step 2 — preparing the environment
Now run(args) begins in earnest. Its first real job is to assemble the environment — the merged bag of all configuration values your app can read.
ConfigurableEnvironment environment = prepareEnvironment(listeners, args);
The environment gathers your application.properties (or .yml), OS environment variables, command-line arguments, and any active profiles — a profile being just a named set of config, like dev or prod, that you switch on to change behavior per deployment. This all happens before any of your beans are created, and that ordering is deliberate: beans frequently need config values (a database URL, a port) at the moment they are built, so the config must already be resolved and waiting.
This is also the point where Boot prints that ASCII banner. Small thing, but it marks the boundary: environment ready, context not yet built.
Step 3 — creating the right kind of context
With the application type known and the environment ready, run creates the context object itself:
context = createApplicationContext(); // picks a class based on WebApplicationType
Here is where the Step 1 decision pays off. For a SERVLET app, Boot instantiates a context class whose full name is a mouthful — AnnotationConfigServletWebServerApplicationContext — but whose meaning is simple: a container that also knows how to run an embedded servlet web server. For a NONE app it picks a plain context with no web machinery at all.
At this moment the context is an empty shell. It knows how to hold beans and how to start a web server, but it contains no beans and has started nothing. Filling it is the next, and biggest, step.
Step 4 — refresh, where the beans actually come alive
The heart of the whole boot is a single method call:
refreshContext(context); // -> context.refresh()
Refresh is the Spring lifecycle step that turns the empty shell into a fully wired, running application. It is worth knowing the sub-steps it runs, in order, because the web server appears in the middle of them:
-
Read all bean definitions. The context scans your packages for
@Component,@Service,@Controllerand friends, and processes every@Configurationclass. Crucially, this is where auto-configuration runs — Boot's mechanism that inspects the classpath and registers sensible default beans (aDataSourceif a JDBC driver is present, a JSON converter if Jackson is present, and so on). After this step the context has a full catalogue of beans, but has not built most of them yet. -
onRefresh()— a hook the servlet context overrides to create the embedded web server. More on this in a moment; this is the key line for our topic. - Instantiate the singletons. The context now actually builds every non-lazy singleton bean and injects their dependencies. Your services, repositories, and controllers become live objects here.
-
finishRefresh()— the final step, which starts the web server accepting traffic.
Notice the shape: the server is created in step 2 but only opened to traffic in step 4, with all your beans instantiated in between. That gap is not an accident, and we will see why it is exactly right.
Where the embedded server comes in
Let me define embedded plainly, because it is the whole trick. Traditionally you built a WAR file and deployed it into a separately installed Tomcat. Spring Boot inverts that: Tomcat is just a library on your classpath, and Boot starts it from inside your application as an ordinary object. The server runs in your process; your process is not deployed into the server. That is what "embedded" means — the server is embedded in your app, not the other way around.
So how does onRefresh() create it? It looks in the context for a special bean: a ServletWebServerFactory. Auto-configuration will have registered one based on the classpath — TomcatServletWebServerFactory when Tomcat is present (the default), or a Jetty or Undertow variant if you swapped the dependency. The context asks that factory to produce a running server:
// inside the servlet context's createWebServer(), simplified
ServletWebServerFactory factory = getWebServerFactory(); // e.g. Tomcat
this.webServer = factory.getWebServer(getSelfInitializer());
The factory is a bean whose only job is to build and configure a web server. getWebServer(...) constructs the Tomcat instance, applies your settings (port, context path, thread pool), and returns it. Because the factory is an ordinary bean, customizing the server is just a matter of configuration or a bean — no server-install step anywhere:
@Bean
WebServerFactoryCustomizer<TomcatServletWebServerFactory> tuning() {
return factory -> {
factory.setPort(9090);
factory.addConnectorCustomizers(connector ->
connector.setProperty("maxThreads", "400"));
};
}
That is the entire reason a Spring Boot fat jar can be launched with java -jar app.jar and just work. The server is a dependency packaged inside the jar, started by your own code. There is no external runtime to match versions with, and the same jar runs identically on your laptop and in a container.
The port opens last — and why that ordering matters
Recall the gap from Step 4: the server is created in onRefresh() but does not accept requests yet. Opening the port is deferred to finishRefresh(), which runs only after every singleton bean has been instantiated.
This ordering is a quiet piece of correctness. If Tomcat began accepting connections the instant it was created, requests could arrive while your services and database connections were still being wired — and hit half-built beans. By holding the connectors closed until the context is fully initialized, Boot guarantees that the first request only lands on a completely assembled application. The port opening is the signal that the app is ready to serve.
There is one genuine trap hiding here, though. After refresh returns — port already open — run does two more things:
callRunners(context, args); // your ApplicationRunner / CommandLineRunner beans
listeners.ready(context, ...); // publishes ApplicationReadyEvent
A CommandLineRunner is a bean whose code runs once at startup, right after the context is ready — handy for warming a cache or seeding data:
@Component
class WarmupRunner implements CommandLineRunner {
public void run(String... args) {
cache.preload(); // careful: the port is ALREADY open here
}
}
Because runners execute after finishRefresh has opened the port, Tomcat is already accepting traffic while your runner is still working. A slow runner means real requests can arrive before its warm-up finishes. If a task must complete before any request is served, a CommandLineRunner is the wrong place for it — do that work inside a bean's initialization instead, which happens back in step 3, before the port opens.
A few gotchas worth carrying
The port is already taken. If something else holds 8080, the server fails during finishRefresh and the whole boot aborts with Port 8080 was already in use. Startup is all-or-nothing: a web server that cannot bind is treated as a failed application, not a warning.
A web app that exits immediately. If you expected a running server but the process starts and stops, the usual cause is a missing spring-boot-starter-web — the constructor deduced WebApplicationType.NONE, so no server was ever created. The fix is a dependency, not code.
Graceful shutdown. The server that run started is a managed bean, so it participates in shutdown too. Enabling graceful shutdown lets in-flight requests finish before the process dies, instead of being cut off:
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
The whole flow in one breath
SpringApplication.run constructs an application object that inspects the classpath to decide it is a servlet web app; prepares the environment so config is ready before any bean; creates a servlet-aware context; and then refreshes it. Refresh reads every bean definition (auto-configuration included), asks a factory bean to create an embedded Tomcat, instantiates all your singletons, and finally opens the port so the first request meets a fully assembled app. Runners fire last, after traffic is already flowing.
Once you can see those phases, "embedded Tomcat" stops being magic. It is just a library, started as a bean, at a carefully chosen point in an ordered sequence — and the fat jar that runs anywhere is the natural consequence of the server living inside your app instead of your app living inside a server.
Top comments (0)