Skip to main content

Spring Bean Scopes: Singleton, Prototype, Request, Session and the Prototype-in-Singleton Trap

Instance counts for singleton, prototype, request, session and application scope taken from real runs, the prototype-in-singleton trap and four ways to fix it, a singleton leaking one caller’s data into another, and request scope without a proxy failing at start-up. Tested on Spring Boot 4.1.1, Spring Framework 7.0.9 and Java 25.

A singleton bean called use() five times, and each call returned prototype.serial(). The bean it called is a prototype, which every tutorial describes as “a new instance every time”. The five answers were [1, 1, 1, 1, 1]: one instance, built once, used five times. Nothing was misconfigured. The prototype really does produce a new instance every time the container is asked; the singleton simply asked once, when it was built, and kept the answer. This article counts instances, with the container itself doing the counting, for every scope Spring gives you: singleton, prototype, and the web scopes request, session and application. It then reproduces the prototype-in-singleton trap, fixes it four ways, shows a singleton leaking one caller’s data into another’s, and ends with the start-up failure you get from a request-scoped bean without a proxy. Every code block links to a file in the core-beans module of a companion repository that compiles and runs, and every console block is quoted from a transcript that test run wrote, not typed in by hand.
Versions. Spring Boot 4.1.1 and Spring Framework 7.0.9 (both poms were published to Maven Central on 20 August 2026), on Java 25 (LTS, Temurin 25.0.4.1). The singleton and prototype scenarios use a plain Spring ApplicationContext; the web-scope scenario runs a real Spring Boot web application on a random port and calls it over HTTP with two clients.

A scope is a rule about how long a bean lives, and who shares it

Every bean the container manages has a scope. It answers two questions: when is a new instance made, and who gets the same one? If you have never chosen a scope, you have used singleton, the default: one instance per container, shared by everything that asks for it. The other four are opt-in.
singleton one instance, alive as long as the container prototype new #1 new #2 new #3 made on demand, then forgotten application one per servlet context session session A session B request request 1 request 2 request 3 request 4 request 5 The scope decides how long an instance lives and who shares it. Nothing else about the bean changes.
Read each row as a lifetime along the same timeline. The singleton is one bar for the whole container life. The prototype is a row of short, independent bars, because each is made on demand. The three web scopes hang off HTTP: an application bean is one bar, sessions are one bar per client, requests are one bar per HTTP request. The rest of the article is these bars, counted.
ScopeOne instance perCreatedNeeds a web application?
singleton (default)containerat start-up, unless @Lazyno
prototyperequest to the containerevery time the container is askedno
requestHTTP requeston first use in that requestyes
sessionHTTP sessionon first use in that sessionyes
applicationservlet contexton first useyes
The default needs no annotation. This class is a singleton purely because nobody said otherwise, and its constructor takes a serial number from a counter so a test can tell how many of it were ever built (SingletonBean.java):
/** No {@code @Scope}: the default. One instance per container, created at start-up. */
public class SingletonBean {

    private final int serial = Instances.next("singleton");

    public int serial() {
        return serial;
    }

    @PreDestroy
    void destroy() {
        Trace.log("singleton @PreDestroy called");
    }
}
Going deeper: how the instances are counted

Every demo bean in the repository calls Instances.next("…") from a field initialiser, which increments a named counter and returns the new value as that instance’s serial (Instances.java). Nothing about scope is inferred: the number in a transcript is the number of times the JVM actually ran that constructor. The counters are static, so each scenario in the tests calls Instances.reset() first.

The rest of the article reads the counts, but the counting itself is the general technique: if you are unsure what scope a bean really has, put a counter in its constructor and look.

Going deeper

Singleton and prototype, counted

A prototype bean is declared with @Scope("prototype") (PrototypeBean.java):
/** A new instance every time the container is asked for one. */
@Scope("prototype")
public class PrototypeBean {

    private final int serial = Instances.next("prototype");

    public int serial() {
        return serial;
    }

    @PreDestroy
    void destroy() {
        Trace.log("prototype @PreDestroy called");
    }
}
A third bean is a singleton with @Lazy added, which changes when it is built but not how many (LazySingletonBean.java):
/** Still a singleton, but not created until something asks for it. */
@Lazy
public class LazySingletonBean {

    private final int serial = Instances.next("lazy");

    public int serial() {
        return serial;
    }
}
The test creates a plain context with all three, and prints the instance counts at three moments. 01-instance-counts.txt is the transcript:
right after refresh():
  singleton instances : 1
  @Lazy singleton     : 0
  prototype           : 0

after getBean() twice for singleton and lazy, three times for prototype:
  singleton instances : 1   same object both times: true
  @Lazy singleton     : 1   same object both times: true
  prototype           : 3   serials: 1, 2, 3

two separate contexts, each with SingletonBean:
  singleton instances : 2   same object across contexts: false
Right after start-up the singleton already exists and the @Lazy one and the prototype do not. Asking for the singleton twice gives the same object; asking for the prototype three times builds three, with serials 1, 2, 3.
“Singleton” means one per container, not one per JVM. The last block of the transcript starts two contexts that both register SingletonBean: two instances, and same object across contexts: false. The Java pattern called singleton is enforced by the class; Spring’s singleton is a promise made by one container, and a second container in the same process, such as a test context, or a parent and a child, makes its own.
Going deeper: the container does not destroy prototypes

For a singleton the container owns the whole life: it builds the bean, and calls its @PreDestroy method when the context closes. For a prototype it builds the bean, hands it over, and then no longer keeps track of it. 02-prototype-destroy.txt closes a context that produced one of each:

prototype instances created: 1
callbacks after close(): [singleton @PreDestroy called]

Both classes have a @PreDestroy method (the prototype’s is in PrototypeBean.java), and only the singleton’s ran. If a prototype holds something that must be released — a file, a connection, a thread — the code that asked for it has to release it. This is one of the reasons to think twice before making a bean a prototype at all.

The lifecycle callbacks that do run, and in what order, are the subject of the article on the bean lifecycle.

Going deeper

The prototype-in-singleton trap

The reason for the opening numbers is the order things happen. The container builds a singleton once, at start-up. While building it, it resolves each constructor parameter, and for a prototype parameter that means creating one instance right then and passing it in. From that moment the singleton holds a reference to a single object, and calling a method on that reference does not go back to the container. The class that does this is tiny (NaiveConsumer.java):
/** The trap: a singleton that receives a prototype through its constructor. */
public class NaiveConsumer {

    private final PrototypeBean prototype;

    public NaiveConsumer(PrototypeBean prototype) {
        this.prototype = prototype;
    }

    public int use() {
        return prototype.serial();
    }
}
Constructor injection: asked once NaiveConsumer singleton, built once PrototypeBean #1 the only one ever built use() x 5 serials: [1, 1, 1, 1, 1] ObjectProvider: asked every time ProviderConsumer singleton, built once #1 #2 #3 #4 #5 The prototype is only as fresh as the code path that asks for it.
The top half is the trap: one prototype instance, wired in at construction, reached five times. The bottom half is the shape of every fix in the next section: the singleton does not hold the prototype, it holds something that can produce one when needed. The picture is the whole lesson of this section; the code below only makes it countable. The first row of 03-prototype-in-singleton.txt is the naive consumer, called five times:
how the prototype is obtained      serials seen           instances built
---------------------------------- ---------------------- ---------------
constructor injection (naive)      [1, 1, 1, 1, 1]        1
The fingerprint. A bean documented as new-per-use, and a log or a counter that shows the same serial, id or timestamp on every call. There is no exception and no warning; the symptom is state that should have been fresh being shared. It is worst when the prototype holds per-user data or a non-thread-safe helper such as a formatter or a builder, because then every thread that uses the singleton is silently sharing one instance of it.
Going deeper: why Spring does not warn about it

From the container’s point of view nothing is wrong. You asked for a singleton with a dependency on a prototype-scoped type; it resolved the dependency the same way it resolves any other. The scope describes how the container hands out instances, and it has no way to know that you intended it to be consulted on every method call. The reference chapter on scopes has a section on exactly this case, and the next section shows the mechanisms it describes with numbers.

The same mismatch exists for every scope that is shorter-lived than the bean holding it. A request-scoped bean injected into a singleton is the same mistake, and the framework does detect that one, at start-up, which is why it has its own section near the end.

Going deeper

Four ways to get a fresh prototype, and the one to avoid

Five consumer classes, each a singleton that calls a method five times, obtain their prototype in five different ways: the naive one above and four alternatives. The complete result, from the same transcript (03-prototype-in-singleton.txt):
how the prototype is obtained      serials seen           instances built
---------------------------------- ---------------------- ---------------
constructor injection (naive)      [1, 1, 1, 1, 1]        1
ObjectProvider.getObject()         [1, 2, 3, 4, 5]        5
@Lookup method                     [1, 2, 3, 4, 5]        5
scoped proxy (TARGET_CLASS)        [1, 2, 3, 4, 5]        5
ApplicationContext.getBean()       [1, 2, 3, 4, 5]        5

ProxyConsumer's injected reference is a com.ankurm.corebeans.scopes.ScopedPrototypeBean$$SpringCGLIB$$0
All four fixes deliver five distinct instances; the naive version delivers one. They differ in cost and in how much they couple your class to Spring. The first is the one to reach for. It asks a provider that the container injects (ProviderConsumer.java):
/** Fix 1: inject a provider and ask it every time. */
public class ProviderConsumer {

    private final ObjectProvider<PrototypeBean> provider;

    public ProviderConsumer(ObjectProvider<PrototypeBean> provider) {
        this.provider = provider;
    }

    public int use() {
        return provider.getObject().serial();
    }
}
ObjectProvider is the same type the previous article used to make a dependency optional; here it is used because getObject() reaches the container every time it is called, and for a prototype the container answers with a new instance.
Fix 4 is a habit to avoid, and it is in the table on purpose. Injecting the ApplicationContext and calling getBean() works — the fourth row shows five instances — but the class now depends on the container and cannot be built or tested without one, and the dependency is invisible in the constructor signature. It is the “service locator” pattern; ObjectProvider gives the same behaviour with a narrower dependency.
Going deeper: @Lookup, a method Spring implements for you

The second fix declares an abstract method and lets Spring subclass the class at run time and implement it as a getBean() call (LookupConsumer.java):

/** Fix 2: Spring subclasses this at runtime and overrides the abstract method to call getBean(). */
public abstract class LookupConsumer {

    @Lookup
    protected abstract PrototypeBean create();

    public int use() {
        return create().serial();
    }
}

According to the reference chapter the class and the method must be subclassable (not final) and the container has to be the one creating the bean. It keeps the Spring dependency out of your constructor and reads naturally, at the cost of a little magic and a class that cannot be used directly in a unit test without a Spring-created subclass. The reference chapter on method injection covers the rules.

Going deeper: a scoped proxy, and what you are really holding

The third fix changes the prototype bean rather than its consumer: it declares proxyMode = ScopedProxyMode.TARGET_CLASS (ScopedPrototypeBean.java), so what gets injected is not the bean but a generated proxy:

/** Prototype scope behind a proxy: every method call on the injected reference reaches a new target. */
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ScopedPrototypeBean {

    private final int serial = Instances.next("scopedPrototype");

    public int serial() {
        return serial;
    }
}

The consumer looks completely ordinary (ProxyConsumer.java) and even reports what it received; the last line of the transcript shows it:

/** Fix 3: inject a scoped proxy; the proxy fetches a fresh target for every call. */
public class ProxyConsumer {

    private final ScopedPrototypeBean prototype;

    public ProxyConsumer(ScopedPrototypeBean prototype) {
        this.prototype = prototype;
    }

    public int use() {
        return prototype.serial();
    }

    public String injectedClass() {
        return prototype.getClass().getName();
    }
}
ProxyConsumer's injected reference is a com.ankurm.corebeans.scopes.ScopedPrototypeBean$$SpringCGLIB$$0

The proxy is a subclass generated by Spring ($$SpringCGLIB$$0). Every method call on it asks the container for a target and delegates to it, so each call reaches a new instance. Two consequences follow. First, a call to serial() is two objects deep, and the object you are holding is not the object that does the work, so ==, equals and any class check see the proxy. Second, every call gets a new instance, including two consecutive calls that you meant to be one unit of work — if you need one instance for a series of calls, fetch it once through a provider and hold it locally.

Going deeper

A singleton is shared by every thread, so its fields are too

Because a singleton is one instance, every request thread that reaches it uses the same fields. If a field holds something that belongs to one caller — the current user, a partly built result — a second caller can overwrite it. This is the most common real-world consequence of the default scope, and it is invisible in code review because the class looks fine on its own (UnsafeGreeter.java):
/**
 * A singleton that keeps per-caller data in a field. The {@code pause} hook lets a test force the
 * interleaving that production traffic produces only occasionally, so the demonstration is exact.
 */
public class UnsafeGreeter {

    private String currentUser;

    public String greet(String user, Runnable pause) {
        this.currentUser = user;
        pause.run();
        return "Hello, " + currentUser;
    }
}
The test forces the unlucky timing that production produces only occasionally: Alice sets her name and pauses, Bob sets his name meanwhile, then Alice reads. A safe version of the class keeps the value in a local variable (SafeGreeter.java):
/** The same singleton with the state in a parameter and a local variable: nothing shared to corrupt. */
public class SafeGreeter {

    public String greet(String user, Runnable pause) {
        String currentUser = user;
        pause.run();
        return "Hello, " + currentUser;
    }
}
1. alice sets field field = alice alice pauses waits for bob 2. bob sets field field = bob 3. alice reads field field = bob Hello, bob greeted as bob One instance, one field, two threads: whoever wrote last is who the first thread reads.
The boxes are the four steps in time order: Alice writes the shared field, Bob writes it, and only then does Alice read what she thought was hers. The red boxes are where the bug lives: the read at step 3 returns Bob’s value. The latch in the test exists only to make this ordering happen every time. The result, from 04-singleton-thread-safety.txt:
UnsafeGreeter (state in a field)   : alice's call returned "Hello, bob"
SafeGreeter   (state in a local)   : alice's call returned "Hello, alice"
The fingerprint. Data from one user appearing in another user’s response, intermittently, only under load, and never in a single-threaded test. The cause is almost always an instance field on a singleton that is written by request-handling code. The remedy is to keep per-call state in locals and parameters, or in a request-scoped bean if it truly belongs to the request.
Going deeper: how the test forces the interleaving

Real traffic hits this bug once in a while, which makes it hard to demonstrate honestly. The test drives it deterministically with a CountDownLatch: Alice’s pause hook blocks on the latch, and Bob’s hook counts it down after he has set his name (ScopesTest.java):

    /** Alice sets her name and pauses; Bob sets his name meanwhile; Alice then reads. Returns Alice's result. */
    private static String interleave(java.util.function.BiFunction<String, Runnable, String> greet) throws Exception {
        CountDownLatch bobHasSet = new CountDownLatch(1);
        String[] aliceResult = new String[1];
        Thread alice = Thread.ofPlatform().start(() -> aliceResult[0] = greet.apply("alice", () -> {
            try {
                bobHasSet.await(5, TimeUnit.SECONDS);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }));
        Thread bob = Thread.ofPlatform().start(() -> greet.apply("bob", bobHasSet::countDown));
        alice.join();
        bob.join();
        return aliceResult[0];
    }

The point of the exercise is that the unsafe class needs no unusual code to be wrong: it is a field and two lines. A stateless singleton — the shape most services and controllers already have — is safe by construction.

For per-request context that has to travel through many method calls, a ThreadLocal is the traditional answer and has sharp edges of its own; the trade-offs are worked through in Scoped Values vs ThreadLocal.

Going deeper

Request, session and application scope, over real HTTP

The three web scopes tie a bean to something an HTTP client can see. To count them honestly the test starts a real Spring Boot web application on a random port and calls it with two separate HTTP clients, each with its own cookie jar, so each gets its own session. The beans are small (RequestBean.java); the annotation does two things at once:
/** {@code @RequestScope} is {@code @Scope("request")} plus a TARGET_CLASS scoped proxy. */
@Component
@RequestScope
public class RequestBean {

    private final int serial = Instances.next("request");

    public int serial() {
        return serial;
    }
}
The controller that uses them is a singleton with all three injected through its constructor (ScopesController.java), which is exactly the trap of the previous sections — a long-lived bean holding a shorter-lived one:
/**
 * A singleton controller holding three differently scoped collaborators. The injected references
 * are scoped proxies, so each request reaches the instance that belongs to it.
 * Diagnostic only: delete before shipping.
 */
@RestController
public class ScopesController {

    private final RequestBean request;
    private final SessionBean session;
    private final ApplicationBean application;

    public ScopesController(RequestBean request, SessionBean session, ApplicationBean application) {
        this.request = request;
        this.session = session;
        this.application = application;
    }

    @GetMapping("/scopes")
    public String scopes() {
        return "request=" + request.serial() + " session=" + session.serial() + " application="
                + application.serial() + " injectedRequestClass=" + request.getClass().getSimpleName();
    }
}
It works because the injected objects are not the beans but proxies, as the last column of 05-web-scopes.txt shows:
client     response
A #1       request=1 session=1 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
A #2       request=2 session=1 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
A #3       request=3 session=1 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
B #1       request=4 session=2 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0
B #2       request=5 session=2 application=1 injectedRequestClass=RequestBean$$SpringCGLIB$$0

instances constructed: request=5 session=2 application=1
Client A Client B req 1 req 2 req 3 req 4 req 5 session 1 client A session 2 client B application 1 shared by all request = 5 (one per HTTP request) session = 2 (one per client) application = 1
Client A made three requests and client B two, so five request-scoped instances were built. Each client has its own session cookie, so the session-scoped instance is 1 for A and 2 for B. There is one application-scoped instance and both clients see it. The counts at the foot of the transcript, request=5 session=2 application=1, are read from constructor counters, not inferred from the serials.
The injected class is a proxy. injectedRequestClass=RequestBean$$SpringCGLIB$$0. @RequestScope is shorthand for @Scope("request") plus a class-based scoped proxy, and that proxy is the reason the singleton controller can hold a request-scoped collaborator at all: each call finds the instance that belongs to the request being handled. @SessionScope and @ApplicationScope do the same for their scopes.
Going deeper: application scope is not always “singleton”

An application-scoped bean lives as long as the servlet context, not the JVM (ApplicationBean.java). In a single Spring Boot application there is one servlet context, so the bean behaves exactly like a singleton and is easy to mistake for one. The difference would show up when several servlet contexts share a JVM, such as several web applications deployed to one server: each has its own, whereas a singleton belongs to whichever Spring container defined it. That case is not exercised in the repository.

The repository’s controller also states its status plainly in a comment: it is a diagnostic endpoint and has no authorisation. Delete it before shipping.

Going deeper

Request scope without a proxy fails at start-up

Take the proxy away and the same idea does not survive contact with the container. A request-scoped bean whose @Scope("request") has the default proxy mode, injected into a singleton, is asking for a request-scoped instance while the singleton is being built — at start-up, when no request exists (BrokenRequestConsumer.java):
/** A singleton that takes a request-scoped bean WITHOUT a scoped proxy: the classic start-up failure. */
public class BrokenRequestConsumer {

    /** Registered by hand in the test, with proxyMode left at its default (NO). */
    @Scope("request")
    public static class RawRequestBean {
    }

    public BrokenRequestConsumer(RawRequestBean bean) {
    }
}
06-request-scope-without-proxy.txt has what the container says:
top exception : org.springframework.beans.factory.UnsatisfiedDependencyException
top message   : Error creating bean with name 'brokenRequestConsumer': Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'brokenRequestConsumer.RawRequestBean': Scope 'request' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton

root cause    : java.lang.IllegalStateException: No thread-bound request found: Are you referring to request attributes outside of an actual web request, or processing a request outside of the originally receiving thread? If you are actually operating within a web request and still receive this message, your code is probably running outside of DispatcherServlet: In this case, use RequestContextListener or RequestContextFilter to expose the current request.
The fingerprint. Scope 'request' is not active for the current thread; consider defining a scoped proxy for this bean if you intend to refer to it from a singleton, with the root cause No thread-bound request found. The message is unusually helpful: it names the fix. The same root message appears at run time, not only at start-up, when a request-scoped bean is used on a thread that is not handling the request — for example inside an @Async method or a task you started yourself — because the request is bound to the receiving thread.
The two fixes are the same ones you have already seen: give the scoped bean a proxy (@RequestScope does it for you), or inject an ObjectProvider and call it inside the request.
Going deeper: the request is bound to a thread

The last sentence of the root message — “processing a request outside of the originally receiving thread” — explains a whole family of bugs. Request scope works by looking up request attributes that the servlet container bound to the current thread. Work handed to another thread finds none. The same is true of anything else stored per thread, including security context by default, which is why those features have to be propagated deliberately; the @Async article shows the executor side of it.

The test that produced this transcript builds a web application context around a mock servlet context instead of starting a server, because the failure happens during refresh(), before any request could be served.

Going deeper

Which scope, and when

Most beans should be singletons and stateless, and that is where the defaults point you. The other scopes each solve one problem:
SituationUseWatch out for
a service, repository or controller with no per-caller statesingleton (the default)instance fields written by request code
an expensive bean that may never be neededsingleton with @Lazythe first caller pays the start-up cost, and start-up errors surface late
a stateful helper needed fresh each timeprototype, taken through ObjectProviderthe container never calls its destroy method
data that belongs to one HTTP request@RequestScopethe proxy, and any thread other than the request thread
data that belongs to one user session@SessionScopeevery active session holds its own instance in memory
a value shared by the whole web application@ApplicationScope or a singletonlooks like a singleton but is not tied to the container
Should you even care, on a small project? If your beans are stateless singletons you can ignore prototypes and the web scopes for a long time; this article’s value then is the thread-safety section and the habit of putting a counter in a constructor when you are not sure. If you do reach for a prototype, the honest advice is to ask first whether a plain new inside a factory method would be clearer — a prototype bean buys dependency injection for its dependencies at the price of the trap above and no destroy callback. That last point is opinion, not measurement.
Going deeper: beyond the five scopes

The reference chapter also describes a WebSocket scope and shows how to define your own scope by implementing Scope and registering it. Neither is needed for ordinary applications, and neither is exercised in the repository; both are documented on the page linked below. Custom scopes are how frameworks implement things such as a per-tenant or per-transaction lifetime.

Going deeper

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.