CodeGym /Courses /Spring Core /Eager and lazy initialization

Eager and lazy initialization

Spring Core
Level 10, Lesson 3
Available

1. The moment a bean lifecycle starts

Up to this point, we have treated the lifecycle as a sequence of phases: creation → injection → init → work → destroy. But this chain does not hang in the air — it starts at a specific moment in time. And that is where the interesting part begins: Spring has a “default” behavior, and there is a way to deviate from it. That affects both startup speed and error diagnosis.

It is easy to think of @Lazy as a tool for awkward moments in wiring. Here, another angle matters: the same annotation shifts the moment when a bean actually goes through its lifecycle.

If we simplify it with an everyday analogy, the Spring container is like a coffee shop before opening. You can brew all the coffee in advance, arrange everything, and open the doors fully ready (that is eager). Or you can open the doors first and prepare some items “on demand” after that (that is lazy). In both cases, you will eventually get your coffee, but the difference is when you realize that you have run out of cups and the grinder has broken.

What matters here is to see @Lazy not as an “application accelerator,” but as a shift in the point at which a bean goes through its lifecycle phases.

2. Eager by default

By default, ApplicationContext (in our course, most often AnnotationConfigApplicationContext) behaves quite directly: it tries to create all singleton beans right at startup. That is exactly why Spring loves the fail-fast approach: if wiring is broken or init logic fails, you learn about it not “sometime later,” but right on startup.

Here, we keep the focus on the usual case — singleton beans. That is enough to understand how eager/lazy shifts the lifecycle start moment.

Mini eager demo

Now let us make a tiny example that you can run ten times in a row — and it will behave the same each time. We will deliberately add System.out.println(...) in the constructor and in @PostConstruct so we can see both phases: “the object appeared” and “the object finished preparing after dependency injection.”

import jakarta.annotation.PostConstruct;

class StartupBean {

    StartupBean() {
        // Constructor: shows the moment the "object is created"
        System.out.println("StartupBean: constructor"); // StartupBean: constructor
    }

    @PostConstruct
    void init() {
        // Init phase: runs after dependency injection
        System.out.println("StartupBean: @PostConstruct"); // StartupBean: @PostConstruct
    }
}

Now register this bean through configuration:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class EagerDemoConfig {

    @Bean
    StartupBean startupBean() {
        // This method will be called by the container when creating the bean
        return new StartupBean();
    }
}

And start the context:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class EagerDemoApp {
    public static void main(String[] args) {
        // try-with-resources guarantees proper context closure (and destroy-phase calls)
        try (var ctx = new AnnotationConfigApplicationContext(EagerDemoConfig.class)) {
            System.out.println("Context started"); // Context started
        }
    }
}

If you run this, you will see that the messages from the constructor and @PostConstruct are printed before “Context started”. That is eager: the container created and prepared the bean at startup, even if you never called ctx.getBean(StartupBean.class).

Eager runs the whole lifecycle

At this point, a beginner often thinks: “Okay, eager is just when the object is created in advance.” In practice, the more important thing is this: eager starts the entire lifecycle chain. That means not only new, but also dependency injection, the init phase, and any checks.

If your bean checks configuration in @PostConstruct and throws an exception, eager will make the application fail at startup. That sounds scary, but in reality it is often very useful: it is better to fail immediately than after 20 minutes of work (especially if this is a service that should run reliably for hours).

3. Lazy: @Lazy and the moment a bean is created

Now let us add the second strategy. Spring has the @Lazy annotation, which tells the container: “Do not create this bean at startup until it is needed.” That sounds like a student’s dream on Monday morning (I get it), but the container has its own logic: if nobody asked for the bean, its lifecycle simply does not begin.

At the same time, it is important to understand that lazy is not some separate “half-bean.” It is an ordinary bean with all lifecycle phases — they just start later, when someone first requests the bean via getBean(...) or when the container needs it for the first time to satisfy a dependency.

@Lazy on a @Bean method

Let us take the same example, but mark the bean as @Lazy.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;

@Configuration
class LazyDemoConfig {

    @Bean
    @Lazy // Ask Spring to delay bean creation until the first access
    StartupBean startupBean() {
        return new StartupBean();
    }
}

And now the main method, which first starts the context and then asks for the bean:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class LazyDemoApp {
    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext(LazyDemoConfig.class)) {
            // At this point the bean has not been created yet (it is lazy)
            System.out.println("Context started"); // Context started

            // The first access to the bean triggers its creation + init phase
            ctx.getBean(StartupBean.class);
        }
    }
}

The output will look roughly like this:

Context started
StartupBean: constructor
StartupBean: @PostConstruct

So now the bean lifecycle (creation, init) has moved from context startup to the moment of getBean(...).

And this is where it is important to say the key sentence of this lecture. @Lazy postpones not only the constructor, but also dependency injection and the init phase. If you wanted to “just delay heavy preparation,” then yes — you delayed it. But you also delayed the discovery of potential problems.

@Lazy on a @Component

In a real project, many beans are registered through component scanning, that is, through @Component/@Service/@Repository. You can also put @Lazy on the class.

import jakarta.annotation.PostConstruct;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;

@Lazy // The component will be created only on first real use
@Component
class LazyComponent {

    LazyComponent() {
        // We will get here only when the component is actually needed
        System.out.println("LazyComponent: constructor"); // LazyComponent: constructor
    }

    @PostConstruct
    void init() {
        // The init phase is also delayed until component creation
        System.out.println("LazyComponent: @PostConstruct"); // LazyComponent: @PostConstruct
    }
}

On a normal context startup, such a component will not be created until someone asks for it (directly or indirectly). This is often convenient for “support” objects that are not really needed all the time.

A caveat about the dependency graph

There is a trap here that even more than beginners fall into. You put @Lazy on a bean, start the application, and it still gets created at startup. You look at the screen like it betrayed you. In fact, Spring is simply honest: if your eager singleton depends directly on a lazy bean, then when creating the eager singleton the container needs to provide that dependency, and the lazy bean becomes “needed right now.”

To see this, let us make a couple of small classes:

class ExpensiveHelper {
    ExpensiveHelper() {
        // Simulate a "costly" helper object creation
        System.out.println("ExpensiveHelper created"); // ExpensiveHelper created
    }
}

class OrderService {
    OrderService(ExpensiveHelper helper) {
        // helper is needed by the service already at creation time (constructor injection)
        System.out.println("OrderService created"); // OrderService created
    }
}

And the configuration:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;

@Configuration
class GraphConfig {

    @Bean
    OrderService orderService(ExpensiveHelper helper) {
        // Important: while creating OrderService, the container must provide helper
        return new OrderService(helper);
    }

    @Bean
    @Lazy // But this annotation will not help if helper is needed by an eager service right now
    ExpensiveHelper expensiveHelper() {
        return new ExpensiveHelper();
    }
}

If OrderService is created eagerly (by default), then at context startup it will require ExpensiveHelper, and the container will create ExpensiveHelper even if it is marked with @Lazy, because “lazy” means “create it when needed,” and it is already needed.

The practical moral is simple: @Lazy works predictably, but only if you take the dependency graph into account. In our ContextFlow project, this will be especially noticeable with support catalogs and managers that “hang” as dependencies of services.

4. Fail-fast and fail-late

Now we have reached the most “engineering” part. Eager and lazy are not just about startup speed. They are about when you find out that something is wrong. And that is directly tied to where your errors move: to application startup or to the “first real scenario.”

In practice, there are two approaches to life. First: “let the application fail immediately and not pretend it is ready.” Second: “let it start faster, and bring up the complicated parts as needed.” Spring lets you choose. But it does not make that choice free.

An error in the init phase

Let us make a demo bean that “breaks” in @PostConstruct. For example, it expects some flag to be enabled, but ours is disabled.

import jakarta.annotation.PostConstruct;

class FragileBean {

    @PostConstruct
    void init() {
        // Artificially "break" the init phase to show fail-fast/fail-late
        throw new IllegalStateException("Config is invalid"); // Config is invalid
    }
}

Eager version:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class EagerFailConfig {

    @Bean // Eager by default: creation will happen at context startup
    FragileBean fragileBean() {
        return new FragileBean();
    }
}

If you just create the context:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class EagerFailApp {
    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext(EagerFailConfig.class)) {
            // We will not get to this line: the context will fail at startup
            System.out.println("This line will NOT print");
        }
    }
}

Then you will not reach System.out.println(...): the context will fail at startup. That is fail-fast.

And now the lazy version:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;

@Configuration
class LazyFailConfig {

    @Bean
    @Lazy // Creation is delayed until the first request for the bean
    FragileBean fragileBean() {
        return new FragileBean();
    }
}

And the main method:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class LazyFailApp {
    public static void main(String[] args) {
        try (var ctx = new AnnotationConfigApplicationContext(LazyFailConfig.class)) {
            // The context starts successfully because FragileBean has not been created yet
            System.out.println("Context started"); // Context started

            // The first access to FragileBean will trigger the init phase and "explode" here
            ctx.getBean(FragileBean.class);        // boom
        }
    }
}

Here the context starts, but the “bomb” moves to the moment of the first getBean(...) request. The error did not disappear — it just happened “later.”

And that is where the question usually appears: “So what is better?” There is no universal answer, but for the core of the application (the main object graph), it is often more useful to learn about problems earlier. Especially in a learning project, where predictability matters: it started — so everything is assembled.

Wiring errors and the moment they are detected

Another common illusion: “If I make a dependency lazy, the container will stop complaining that the bean is missing.” Sometimes it may indeed seem that @Lazy “fixed” NoSuchBeanDefinitionException. In reality, you just changed the moment when this becomes visible.

If a dependency is missing and it is mandatory, then either the context will fail at startup with eager, or it will fail later on first access (if you managed to defer resolution). From an operational point of view, this is not a “fix,” but a shift in time. In a real project, such a shift may be justified, but only if you clearly understand what you are doing and truly want to “bring up part of the application on demand.”

Here it is very useful to keep a rule from earlier in mind: @Lazy is a tool. It should not replace a proper dependency graph design and should not turn into “an annotation bandage for everything.”

5. Fast startup and the cost of the first call

When people first hear about lazy, they often think about performance in the “the application starts faster — so that is good” sense. But in engineering life there is one boring law of pain conservation: if you did not do something at startup, you will most likely do it later, when the user already needs it.

So lazy often turns a “long startup” into a “long first call.” Sometimes that is an excellent compromise, sometimes it is bad UX even in a console application like ours. So let us look very concretely at where we save and where we pay.

Mini timing test

Let us make a bean that “takes a long time to initialize.” We will not connect any profilers — we will just use Thread.sleep(...) as a simulation of heavy preparation. (In reality, this could be reading files, building an index, preparing a directory, checking permissions, and so on.)

import jakarta.annotation.PostConstruct;

class SlowBean {

    @PostConstruct
    void init() {
        // Simulate heavy preparation in the init phase
        try {
            Thread.sleep(300);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("SlowBean init interrupted", e);
        }
        System.out.println("SlowBean ready"); // SlowBean ready
    }
}

Eager configuration:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class EagerSlowConfig {

    @Bean // Eager by default: SlowBean will be created at context startup
    SlowBean slowBean() {
        return new SlowBean();
    }
}

And the timing:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class EagerSlowApp {
    public static void main(String[] args) {
        // Measure the full context startup time
        long start = System.currentTimeMillis();

        try (var ctx = new AnnotationConfigApplicationContext(EagerSlowConfig.class)) {
            long ms = System.currentTimeMillis() - start;
            System.out.println("Context started in " + ms + " ms"); // ~300+ ms
        }
    }
}

Lazy configuration:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;

@Configuration
class LazySlowConfig {

    @Bean
    @Lazy // Delay creation until the first access
    SlowBean slowBean() {
        return new SlowBean();
    }
}

And the timing:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class LazySlowApp {
    public static void main(String[] args) {
        // Here we measure context startup separately from bean creation
        long start = System.currentTimeMillis();

        try (var ctx = new AnnotationConfigApplicationContext(LazySlowConfig.class)) {
            System.out.println("Context started"); // Context started

            // Measure the "cost" of the first bean use
            long before = System.currentTimeMillis();
            ctx.getBean(SlowBean.class);
            long beanMs = System.currentTimeMillis() - before;

            System.out.println("SlowBean created in " + beanMs + " ms"); // ~300+ ms
        }
    }
}

The idea is this: the context starts faster, but you pay the time on getBean(...).

Trade-off table

To avoid turning the eager/lazy choice into a religious war about “who is lazier,” it helps to keep the trade-off in your head explicitly. A table is the easiest way, because the brain compares better that way.

Characteristic Eager (default) Lazy (@Lazy)
Moment a bean is created at context startup at the first real request/use
Moment the init phase runs at startup at first use
Wiring/initialization diagnostics fail-fast (earlier) fail-late (later)
Context startup time usually higher usually lower
First-use latency usually lower can be noticeably higher
Predictability of behavior higher lower (the error appears later)

For a learning application and for most “core” services, eager remains a good default, because it makes the system easier to understand: it started — so it is assembled, and the init phases have run. We keep lazy as a tool for special cases when a bean is truly heavy and truly not always needed.

6. Eager and lazy in ContextFlow

Now let us pin this topic to our domain, but without unnecessary details: what matters is the choice between fail-fast and “I will create it later if needed.” In ContextFlow, we have a “core” — services for order processing scenarios and basic infrastructure (storage, auditing, notifications) — and there are “supporting things” that may not always be needed.

If you keep the model “the core must be ready at startup” in mind, you almost automatically arrive at this idea: core application services (for example, a hypothetical OrderPlacementService) are better created eagerly so that wiring and configuration errors are caught immediately. But template catalogs or output managers that only “turn on” in certain scenarios can sometimes be made lazy — but only if you are ready to accept the cost of the first encounter.

For example, imagine that NotificationTemplateCatalog prepares an internal map of templates, and ReportOutputManager prepares a directory for report output. If you start the application just to create an order and send a console notification, maybe the report is not needed at all. In that case, report infrastructure may be a candidate for lazy. But if a report is needed almost always, then lazy gives no benefit: you just move the work into the first call, and the point of “speedup” disappears.

A practical rule that works well for beginners: if you are not sure, make it eager. Eager is easier to debug and easier to explain to yourself. Use lazy when you can honestly answer two questions. First: “why is this bean really not needed at startup?” Second: “what happens if it breaks not at startup, but five minutes into runtime?” For template catalogs and output managers, that usually means a pretty boring but useful choice: eager by default.

7. Typical mistakes with @Lazy

Mistake #1: put @Lazy everywhere to speed things up, and then wonder why the app fails at a random moment.
That quickly turns the application into a box of surprises: startup goes nicely, and then suddenly the first real operation fails because of a problem that eager would have caught immediately. If you want faster startup, first make sure you really have “heavy and not always needed” beans, and that you are ready for fail-late.

Mistake #2: think that @Lazy removes an error, when in fact it just shifts it in time.
If you throw an exception in the init phase or wiring cannot be assembled, lazy only means that the problem will appear not at context startup, but at the moment of first use. That is useful as a tool for controlling initialization timing, but bad as a “cure for a red stack trace.”

Mistake #3: expect @Lazy on a bean to work even if an eager bean depends on it directly.
If your main service is created eagerly and requires a dependency through its constructor, the container must provide that dependency right now. So a lazy bean can absolutely be created at startup “despite” expectations. This is not a Spring bug; it is a natural consequence of the dependency graph.

Mistake #4: forget that when bean creation is delayed, its init phase is delayed too (@PostConstruct, initMethod, afterPropertiesSet).
Sometimes a developer puts @Lazy in place, thinking that “only the object will be created later,” and then wonders why cache preparation, configuration checks, or diagnostic output also moved. It is important to remember: the lifecycle starts as a whole when the bean is actually created.

Mistake #5: use lazy as a way to hide poor dependency architecture.
If you add @Lazy because otherwise there is a dependency cycle or “it does not assemble,” most of the time you are not solving the problem — you are making it less visible at startup. It is much more useful to first ask yourself whether responsibilities are mixed up and whether a separate component should be extracted instead of treating the graph with annotations.

1
Task
Spring Core, level 10, lesson 3
Locked
Eager singleton is created at startup
Eager singleton is created at startup
1
Task
Spring Core, level 10, lesson 3
Locked
Lazy bean is created on first request
Lazy bean is created on first request
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION