What 'started' means and why monolith slow startup costs you
"Started" is not one moment. A large monolith passes through at least five milestones, and each one hides different costs:
- Process launch. The runtime starts and loads code. Slow class loading, module imports or large dependency trees show up here.
- Framework ready. The container has wired its components. Eager singletons, reflection-heavy scanning and config parsing hide in this phase.
- Dependencies connected. Database pools, caches and message brokers are reachable. Network round-trips, TLS handshakes and schema checks land here.
- Readiness probe passing. The orchestrator will now route traffic. Warm-up jobs and probe intervals add delay here, even after the app is actually able to serve.
- First request served. Lazy paths, cold caches and JIT warm-up cost the first users latency.
Logs usually say "started" at the second milestone, but the third through fifth are what actually gate a deploy.
Boot time multiplies. A rolling deploy replaces instances in batches, so total rollout time scales with boot time. A rollback is just another deploy, so a slow boot extends incidents. Autoscaling reacts late because new capacity arrives only after readiness passes. Locally, every restart taxes the edit-run loop.
Illustrative estimate (assumed numbers, not a benchmark): rollout time ≈ (instances ÷ batch size) × (boot-to-ready + probe delay). With 40 instances, batches of 4, 90 s to ready and 10 s probe delay, that is 10 × 100 s ≈ 17 minutes per deploy or rollback.
Invest in this when you run a large single deployable and boot time is noticeably slowing rollouts, scale-out or local development.
BOOT MILESTONES (one instance) COSTS HIDDEN IN EACH PHASE
+---------------------------+
| 1. Process launch | class loading, module imports, dep trees
+---------------------------+
|
v
+---------------------------+
| 2. Framework ready | eager singletons, scanning, config parsing
+---------------------------+ <-- logs usually say 'started' here
|
v
+---------------------------+ <-- phases 3-5 actually gate the deploy
| 3. Dependencies connected | DB pools, caches, brokers, TLS, schema
+---------------------------+
|
v
+---------------------------+
| 4. Readiness probe passes | warm-up jobs, probe interval delay
+---------------------------+ <-- orchestrator starts routing traffic
|
v
+---------------------------+
| 5. First request served | lazy paths, cold caches, JIT warm-up
+---------------------------+
|
v
+------------------------------------------------------------------+
| rollout ~= (instances / batch) x (boot-to-ready + probe delay) |
| slow boot also delays: rollbacks, autoscaling, local edit-run |
+------------------------------------------------------------------+
A monolith passes five boot milestones, each hiding different costs. Logs report 'started' at milestone 2, but milestones 3 to 5 gate traffic, so boot-to-ready time is what gets multiplied across rollouts, rollbacks, scale-out and local restarts.
Who should use this approach
This approach fits teams that own a single large deployable and feel its boot time in day-to-day work. Typical signs:
- Platform or SRE teams whose rolling deploys and rollbacks take long enough that boot-to-ready dominates the rollout window.
- Teams running autoscaled services with spiky traffic, where new instances arrive after the spike has already degraded latency.
- Application teams working inside the monolith, where a full restart is part of every edit-run cycle and hot reload does not cover enough of the code.
- Teams planning a decomposition who want to know which parts of the app are actually expensive before drawing service boundaries.
It is less useful if your app already boots in a few seconds, if you deploy rarely and run a fixed fleet with no autoscaling, or if the slow part of your release is something else entirely, such as image builds, test suites or manual approvals. Measure the full pipeline first; if boot is a small slice of it, optimize elsewhere.
How boot-time optimization works
The method is a loop with four steps, and the rest of this article follows it in order:
- Measure. Instrument each boot phase with a monotonic timer and record the total from process start to readiness. This gives a baseline and exposes untracked time.
- Attribute. Use a wall-clock sampling profiler to find which code and which waits (network, disk, locks) account for the slow phases. Wall-clock matters because much of boot is blocked I/O, not CPU.
- Remove, defer or overlap. For each hotspot, pick the cheapest fix that preserves correctness:
- Remove work from boot entirely, by moving migrations to a deploy step or precomputing artifacts at build time.
- Defer work that requests rarely need, behind explicit lazy holders.
- Overlap independent I/O, such as opening pools and fetching config, so their latencies run concurrently instead of adding up.
- Enforce. Wire readiness so it reflects what serving actually needs, and add a CI boot budget so regressions fail a build instead of quietly creeping back.
The underlying principle is that boot should do only the work required to serve the first request correctly and to fail fast on bad configuration. Everything else is either moved to a different time (build, deploy step, first use, after ready) or run in parallel.
Boot profiling: build a baseline and find where wall-clock time goes
Start with coarse instrumentation before reaching for a profiler. Wrap each boot phase in a timer that records a monotonic start time, the elapsed duration and the phase name, then emits one structured log line or tracing span per phase. The usual phases are module or class loading, config resolution, DI container build, connection pool setup, cache warming and route registration. Use a monotonic clock rather than wall-clock timestamps, because NTP adjustments during boot can skew the numbers. Also log one total from process start to the moment readiness flips. The difference between that total and the sum of your phases is untracked time, and it is often where the surprises are.
Run the measurement repeatedly in two modes:
- Cold runs: fresh container and empty caches.
- Warm runs: a restart on the same node.
Record medians and spread, not single runs. Do this on production-like hardware, because environments distort different phases:
- Laptops have fast local disks and no network latency to dependencies.
- CI runners are often CPU-throttled and share I/O with other jobs.
- Production nodes may have CPU limits that slow class loading or JIT warmup, and real round-trips to databases and secret stores.
Once you know which phases are slow, use a sampling profiler or your runtime's built-in startup tracing to produce a flame graph of the boot. Configure it to sample wall-clock time, not CPU time. Much of boot is spent blocked on DNS lookups, TLS handshakes, connection establishment and remote config fetches. A CPU-only profile hides all of that waiting and points you at the wrong code.
import json, sys, time
from contextlib import contextmanager
BOOT_T0 = time.monotonic()
phases = {}
@contextmanager
def phase(name):
start = time.monotonic()
try:
yield
finally:
ms = (time.monotonic() - start) * 1000
phases[name] = ms
# One structured line per phase, easy to aggregate across runs.
print(json.dumps({"event": "boot_phase", "phase": name,
"start_ms": round((start - BOOT_T0) * 1000, 1),
"duration_ms": round(ms, 1)}), file=sys.stderr)
def mark_ready():
total = (time.monotonic() - BOOT_T0) * 1000
# Time not covered by any phase is often where the surprises hide.
untracked = total - sum(phases.values())
print(json.dumps({"event": "boot_ready", "total_ms": round(total, 1),
"untracked_ms": round(untracked, 1)}), file=sys.stderr)
with phase("module_loading"): pass # import app modules
with phase("config_resolution"): pass # read env, fetch remote config/secrets
with phase("di_container"): pass # build the dependency graph
with phase("connection_pools"): pass # open DB/cache pools
with phase("cache_warming"): pass # preload hot data
with phase("route_registration"): pass # register HTTP handlers
mark_ready() # call where the readiness probe would start returning success
A minimal phase timer that uses a monotonic clock to emit one structured log line per boot phase plus a total-to-readiness line that exposes untracked time; the pattern carries over to any language.
The usual culprits in a large monolith's boot path
Once you have a wall-clock baseline, the same handful of patterns tend to account for most of it. They share one trait: work runs on boot because it was convenient to put it there, not because the first request needs it.
| Culprit | Symptom in profile | Candidate fix |
|---|---|---|
| Eager construction of the whole DI graph | Wide, flat band of constructors under the container's refresh/build call; time grows with bean or provider count | Lazy-scope rarely used services; split modules so only needed subgraphs build |
| Synchronous calls to DB, cache, secret store, remote config | Threads parked in socket read, DNS or TLS frames; gaps in timing logs between "connecting" and "connected" | Fetch concurrently, add short timeouts, defer non-critical clients to first use |
| Classpath/module scanning | Deep stacks in reflection, annotation or file-walk code; cost scales with dependency count | Narrow scan roots, use build-time indexes or explicit registration |
| Migrations or schema validation at boot | Long single span holding a DB lock; every replica waits on it | Run migrations as a separate deploy step; validate in CI |
| Large static data loads | I/O and parse frames, heap jump before readiness | Load on demand, memory-map, or precompute at build |
| Import-time side effects | Time attributed to module loading rather than any named initializer | Move work into explicit init functions callable later |
The last row is the hardest to spot. Import-time work runs before your own timing logs start, so it shows up only as unexplained time between process start and the first logged line.
Implementing lazy initialization and parallel startup safely
Start by sorting every boot-time component into two buckets. Anything a request or health check needs within its first second stays eager: the primary database pool, auth key material, routing tables. Anything touched rarely, such as a PDF renderer, an admin-only ML model, or a legacy SOAP client, moves behind a lazy holder. A lazy holder is an object that builds the dependency on first access, guards construction with a lock, and caches the result. Make deferral explicit: a named holder or factory shows up in code review, while a quietly moved import does not.
Run independent work concurrently
Opening connection pools, fetching remote config, and warming caches rarely depend on each other, so start them together and wait for all of them. Put a timeout on every boot-time network call. Without one, a single unreachable dependency stalls the whole process until an orchestrator kills it, and the logs often give no reason.
Keep failures early
Deferring init must not defer errors. Validate configuration and ping critical dependencies at boot, and fail fast with a clear message. A misconfigured service should crash on deploy, not at 3 a.m. on the first rare request.
Risks to plan for
- First-request latency spikes. The first caller pays the construction cost. Warm critical lazy paths before reporting ready.
- Ordering bugs and registry races. Concurrent init that writes to shared plugin or metric registries needs a lock, or the registration must move after the concurrent phase.
- Nondeterministic logs. Interleaved output makes boot logs harder to diff. Tag each line with its component.
- Thundering herds. When a hundred instances start at once, they all open pools at the same moment. Add jitter and keep initial pool sizes small.
import asyncio, random, threading
class Lazy:
"""Builds a dependency on first access, once, under a lock."""
def __init__(self, factory):
self._factory, self._lock = factory, threading.Lock()
self._value, self._built = None, False
def get(self):
if not self._built: # fast path once built
with self._lock: # only one caller constructs
if not self._built:
self._value = self._factory()
self._built = True
return self._value
pdf_renderer = Lazy(build_pdf_renderer) # app-specific factory, defined elsewhere
async def step(name, coro, timeout_s):
# Every boot-time network call gets a timeout and a component tag in errors.
try:
return await asyncio.wait_for(coro, timeout_s)
except asyncio.TimeoutError as exc:
raise RuntimeError(f"[boot:{name}] timed out after {timeout_s}s") from exc
async def boot():
await asyncio.sleep(random.uniform(0, 2.0)) # jitter so a fleet does not connect at once
# Independent work runs concurrently; the first failure aborts boot.
db, config, _ = await asyncio.gather(
step("db", open_db_pool("postgresql://app@db.internal:5432/app", min_size=2), 5),
step("config", fetch_remote_config(""), 3),
step("cache", warm_cache(), 10),
)
validate_config(config) # fail fast on bad config
await step("db-ping", db.execute("SELECT 1"), 2) # prove the critical dependency works
return db, config # register plugins/metrics here, after the concurrent phase
if __name__ == "__main__":
asyncio.run(boot())
A lock-guarded lazy holder for a rarely used subsystem, plus jittered concurrent startup where each network call has a timeout and config and the database are validated before boot completes.
Moving work out of boot and wiring readiness correctly
The fastest init is the one that never runs at boot.
Migrations belong in a separate deploy step, not in application startup. If every instance tries to migrate on boot, you get lock contention and a rollout gated on DDL. Use expand/contract changes: add columns or tables first, deploy code that handles both shapes, then remove the old shape in a later release. During a rolling deploy, old and new versions run side by side, so every schema change has to work with both.
Build-time precompute covers parsed templates, route tables, and compiled config. Key each artifact to its inputs, such as a content hash, so a stale artifact is detected and rebuilt rather than silently served.
Background warming fills caches after the instance reports ready. This is safe only when a cold path is still correct, just slower.
Liveness answers "is this process wedged and worth restarting?" Readiness answers "can this instance serve requests right now?" Readiness should check only what serving needs, such as a usable database pool. If readiness depends on optional downstreams, a single flaky dependency makes pods flap and stalls the rollout. If readiness only checks that the port is open, traffic reaches instances that are still initializing. Liveness should never depend on dependencies.
| Option | Cuts boot by | Main risk |
|---|---|---|
| Lazy init | Deferring work | First-request latency |
| Parallel init | Overlapping I/O | Ordering bugs |
| Out-of-boot migration | Removing DDL | Compatibility discipline |
| Build-time precompute | Shifting to CI | Staleness |
| Background warming | Post-ready work | Cold-path load |
| Structural split | Shrinking the app | Operational cost |
containers:
- name: app
image: registry.example.com/shop-monolith:1.42.0
ports:
- containerPort: 8080
# Startup probe covers boot. Liveness and readiness do not run until it
# succeeds, so a slow boot is not mistaken for a wedged process.
# Budget: 30 failures x 10s = up to 300s to finish init.
startupProbe:
httpGet:
path: /livez
port: 8080
periodSeconds: 10
failureThreshold: 30
# Liveness: is the process itself responsive? The handler must not call
# the database or other downstreams, or an outage triggers restarts.
livenessProbe:
httpGet:
path: /livez
port: 8080
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
# Readiness: can this instance serve now? The handler checks only what
# serving needs (DB pool usable, init done), not optional downstreams
# and not cache warmth, since warming continues after ready.
readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 5
timeoutSeconds: 2
# Tolerate a brief blip before pulling the pod from endpoints,
# which avoids flapping on one slow check.
failureThreshold: 3
successThreshold: 1
A Kubernetes probe configuration that gives a slow boot its own startup budget, keeps liveness free of dependencies, and limits readiness to what the app needs to serve traffic.
Alternatives to tuning boot time
Profiling and restructuring init is not the only way to reduce the pain of a slow boot. Several alternatives attack the symptoms or the root cause differently, and they can be combined with the techniques above.
- Absorb boot time with capacity instead of shrinking it. Keep warm headroom in the fleet, or scale up on a schedule ahead of known peaks, so autoscaling rarely has to wait on a cold boot during a spike. This costs idle compute but needs no code changes.
- Change the rollout strategy. Surge deploys add new instances before removing old ones, and blue-green deploys boot a full parallel fleet and switch traffic at once. Neither makes boot faster, but both decouple user-facing capacity from boot time. Rollback in blue-green can be a traffic switch back to the still-running old fleet, which avoids a second slow boot during an incident.
- Ahead-of-time compilation or process snapshotting. Some runtimes can compile an application to a native binary ahead of time, or checkpoint an initialized process and restore it later. Both can remove much of the class-loading and framework-wiring cost, but they constrain reflection and dynamic loading, change the build pipeline, and need careful handling of anything captured in the snapshot, such as open connections, secrets and random seeds. Check what your runtime actually supports before planning around it.
- Faster local loops without full restarts. Hot reload, running a subset of modules in a dev profile, or testing components in isolation can fix the edit-run problem even if production boot stays slow.
- Structural change. Draw stronger module boundaries inside the monolith so each module loads only what it needs, or extract the heaviest subsystems into separate services. This addresses slowness that comes from coupling rather than from a few hotspots, at the price of more deployables to operate.
A reasonable order is to try rollout and capacity changes first if the goal is only faster, safer deploys, use boot profiling when a small number of hotspots dominate, and reserve structural change for when the profile shows cost spread evenly across the whole codebase.
When to avoid these optimizations
Each technique in that table trades boot time for a cost paid somewhere else. In some systems that cost is too high.
Critical-path subsystems. If the first request after a deploy needs the ORM, the auth client, and the template engine, deferring them only moves the work onto a user. Readiness turns green sooner, but p99 for the first minute after every rollout gets worse. If your SLO measures that window, you have made things worse for users while improving a dashboard.
Services that must fail fast. Eager initialization finds a bad config or an unreachable dependency before the instance takes traffic. Lazy loading finds the same fault mid-request on a live instance. In payment or order-matching paths, a deferred failure is worse than a slow boot.
Immature deploy pipelines. Splitting migrations into expand and contract steps assumes you can sequence releases and confirm each phase finished. Without that discipline, a skipped contract step or an out-of-order rollback leaves the schema in a state no running version expects.
Fragile build-time caching. Precomputed artifacts are only safe if their cache keys capture every input. If the precompute depends on runtime config, feature flags, or data, you will eventually ship stale output. Debugging that can cost more engineering time than the boot savings returned.
Structural slowness. If the profile shows no dominant hotspot, and the time is spread across hundreds of modules that all load because everything imports everything, tuning will give small gains at best. That coupling has to be fixed with module boundaries or decomposition.
Impact on cost and engineering time, and keeping gains with a CI boot budget
The payoff shows up in three places: rollouts, scale-out, and the edit-run loop. The model is simple enough to run with your own inputs.
Illustrative estimate (assumed numbers, not a benchmark). Assume 200 instances, 10% rollout batches (10 batches), 15 s of fixed per-batch overhead (drain, health-check interval), and 20 local restarts per engineer per day.
Rolling deploy time = batches × (boot time + per-batch overhead)
| Metric | Before (90 s boot) | After (25 s boot) |
|---|---|---|
| Rolling deploy | 10 × 105 s ≈ 17.5 min | 10 × 40 s ≈ 6.7 min |
| Boot share of scale-out delay | 90 s | 25 s |
| Local restart wait per engineer/day | 20 × 90 s = 30 min | 20 × 25 s ≈ 8 min |
The scale-out row matters most during a spike. Existing instances absorb the overload until new ones pass readiness, so every second of boot is a second of degraded latency or dropped requests.
These gains come with costs. Profiling takes days of senior time. A boot-time CI job adds minutes to every pipeline. Lazy and parallel init also adds review burden, because reviewers now have to reason about ordering and first-request latency.
To keep the gains, run a boot budget in CI on a fixed reference environment. Shared runners vary too much for this. The check script boots the app several times, takes the median time to readiness, warns above a soft threshold, and fails above a hard one. Keep the per-phase timing logs in production permanently, so a regression points to a specific phase. Give each phase a named owner, so a failing budget lands with a team instead of in a queue.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to monolith slow startup, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.