On this page
- 1. · Concept & Definition
- 2. · Why It Matters
- 3. · How It Works
- Deep Dive — What Happens on One Invocation
- 4. · Java / Jakarta EE Code Examples
- 5. · Configuration, Defaults & Diagnostics
- 6. · Production Usage Patterns
- 7. · Common Pitfalls & Misconceptions
- 8. · Best Practices
- 9. · Architect's Perspective & Trade-offs
- 10. · Interview Q&A
1· Concept & Definition
A stateless session bean (@Stateless) is a session bean that keeps no conversational state for a client between method invocations. Any instance can serve any call, so the container keeps a pool of interchangeable instances and hands one to each invocation. Each instance is used by only one thread at a time.
2· Why It Matters
- The most widely used EJB type; typical transactional service layer of Jakarta EE applications.
- Pool sizing directly limits concurrency and protects downstream resources.
- Misunderstanding "stateless" leads to data leaking between clients.
Stateless session beans are where most Jakarta EE applications keep their transaction boundaries, so their behaviour decides how commits, rollbacks and concurrency work across the whole service layer. The pool is more than an optimisation: because each instance serves one thread at a time and the pool is bounded, it quietly limits how many calls can reach the database at once, which protects the system when sized well and throttles it when sized badly. And the word "stateless" is easily misread as "has no fields", when it really means "keeps nothing for a particular client", a misunderstanding behind some of the hardest data-leak defects to diagnose.
3· How It Works
Lifecycle

| Transition | Container actions |
|---|---|
| Does not exist → method-ready | Constructor, dependency injection, @PostConstruct |
| Business method | Instance taken from pool, services applied, returned to pool |
| Method-ready → does not exist | @PreDestroy when the pool shrinks or on undeploy |
| System exception | Instance discarded without @PreDestroy |
Pooling

| Server | Pool configuration (example) | Default behaviour |
|---|---|---|
| WildFly / JBoss EAP | strict-max-pool (max-pool-size, instance-acquisition-timeout) | Derived from worker/CPU count unless set |
| Open Liberty | Server config for EJB pools | Implementation-defined |
| Payara / GlassFish | steady-pool-size, max-pool-size, pool-resize-quantity | Configurable per bean or globally |
A bounded pool acts as a bulkhead: when all instances are busy, callers wait (up to a timeout) instead of overloading a database or external system. Pool size should reflect the capacity of the resources the bean uses, not the number of HTTP threads.
Stateless behavior
"Stateless" means no client-specific state survives between calls. Instance fields may hold shared, thread-safe or immutable resources (injected services, caches safe for single-thread use) but must never hold data belonging to a particular caller, because the next call — possibly from another user — may get the same instance.
| Field content | Allowed? |
|---|---|
| Injected services, EntityManager, clients | Yes |
| Immutable configuration | Yes |
| Per-call scratch data set and used within one call | Technically yes, discouraged (clear it) |
| User, cart, or request data kept for the next call | No — leaks between clients |
Use cases
- Transactional service facades (
OrderService.place) called from REST resources or web tier. - Throttled access to constrained resources (pool as bulkhead).
- Remote business services (with remote views) in legacy distributed systems.
- Message processing logic reused by MDBs and web clients.
Deep Dive — What Happens on One Invocation
A client never holds a stateless bean instance; it holds a container proxy, and every business call takes the same path through it. The container first checks the caller's roles against any @RolesAllowed declaration. It then applies the transaction attribute, by default REQUIRED, joining the caller's JTA transaction or starting a new one. Next it acquires an instance from the pool, waiting up to the acquisition timeout if all instances are busy, and runs any interceptors bound to the method before the business method itself executes.
On the way back the order reverses. If the method returns normally or throws an application exception, the instance goes back to the pool and the transaction commits if the container started it. A system exception, meaning an unchecked exception not annotated @ApplicationException, marks the transaction for rollback, discards the instance and reaches the caller wrapped in an EJBException. Because all of this lives in the proxy, a call through this from one method to another on the same bean bypasses every step, including a different transaction attribute on the second method.
4· Java / Jakarta EE Code Examples
@Stateless
public class OrderService {
@PersistenceContext EntityManager em;
@Inject PaymentGateway payments;
@Inject Event<OrderPlaced> placed;
// REQUIRED: joins or starts tx
public long place(NewOrder in) {
Order o = Order.from(in);
em.persist(o);
// failure (runtime ex.) → rollback
payments.authorise(o.total(), in.card());
placed.fire(new OrderPlaced(o.getId()));
return o.getId();
}
@TransactionAttribute(TransactionAttributeType.SUPPORTS)
// read-only: no new tx needed
public Optional<OrderDto> find(long id) {
return Optional.ofNullable(em.find(Order.class, id)).map(OrderDto::from);
}
}<!-- WildFly standalone.xml: bounded pool acting as a bulkhead -->
<subsystem xmlns="urn:jboss:domain:ejb3:10.0">
<session-bean>
<stateless>
<bean-instance-pool-ref pool-name="slsb-strict-max-pool"/>
</stateless>
</session-bean>
<pools><bean-instance-pools>
<strict-max-pool name="slsb-strict-max-pool" max-pool-size="32"
instance-acquisition-timeout="5"
instance-acquisition-timeout-unit="SECONDS"/>
</bean-instance-pools></pools>
</subsystem>// ANTI-PATTERN: client data in a stateless bean
@Stateless
public class BadCheckout {
// shared across different clients!
private Cart cart;
public void add(Item i) { if (cart == null) cart = new Cart(); cart.add(i); }
}Most of this behaviour can be reproduced with CDI, which is the usual migration target. The difference is that one shared instance serves all threads, so the pool's throttling has to be stated explicitly.
// Jakarta EE 11 CDI bean + MicroProfile Fault Tolerance
@ApplicationScoped
public class OrderService {
@PersistenceContext EntityManager em; // shareable container proxy
@Inject PaymentGateway payments;
@Transactional // REQUIRED by default
@Bulkhead(32) // replaces the pool limit
public long place(NewOrder in) {
Order o = Order.from(in);
em.persist(o);
payments.authorise(o.total(), in.card());
return o.getId();
}
}
// Differences: one shared instance, so fields must be thread-safe;
// exceptions are not wrapped in EJBException; nothing is discarded.5· Configuration, Defaults & Diagnostics
| Metric | Meaning |
|---|---|
| Pool available count / max | Headroom before callers wait |
| Wait time / acquisition timeouts | Pool too small or slow downstream |
| Invocations, execution time | Load and latency per method |
| Discarded instances | System exceptions occurring |
When a stateless tier slows down, look at the pool before the code. If the available count sits at zero and wait time rises, callers are queuing for instances. The next question is why: if the beans are slow because the database or an external API is slow, enlarging the pool only moves the queue downstream; if downstream has spare capacity, the pool is simply too small. A rising number of discarded instances means system exceptions are occurring, so search the logs for the matching EJBException. On WildFly the statistics are available through the management CLI.
# enable runtime statistics once
/subsystem=ejb3:write-attribute(name=statistics-enabled,value=true)
# then read one bean's pool and timing figures
cd /deployment=shop.war/subsystem=ejb3
./stateless-session-bean=OrderService:read-resource(include-runtime=true)
# check pool-available-count, pool-current-size, wait-time,
# execution-time, invocations and peak-concurrent-invocations6· Production Usage Patterns
- Pools sized to database connection pool and external API limits.
- Read methods annotated
SUPPORTSorNOT_SUPPORTEDto avoid unnecessary transactions. - Stateless beans as the target for incremental migration to
@ApplicationScoped @TransactionalCDI beans.
A common production arrangement is a REST resource that delegates to a stateless facade, with the facade owning the transaction and the pool sized slightly below the data source's maximum connections, so callers wait at the bean with a clear timeout instead of failing on connection acquisition. Teams modernising such systems usually convert facades one at a time to CDI beans, keeping the semantics explicit with @Transactional and a MicroProfile Fault Tolerance @Bulkhead, and verify commit and rollback behaviour with integration tests before removing the EJB module.
7· Common Pitfalls & Misconceptions
- Client state in instance fields.
- Pools larger than downstream capacity — contention moves to the database.
- Pools too small — requests queue and time out under load.
- Expecting
@PreDestroyafter system exceptions.
8· Best Practices
- Keep instances free of client data.
- Size pools from downstream capacity and measure wait times.
- Use explicit transaction attributes for non-default behaviour.
- Keep business methods coarse-grained.
9· Architect's Perspective & Trade-offs
| Aspect | @Stateless EJB | @ApplicationScoped CDI bean |
|---|---|---|
| Instances | Pool, one thread per instance | One shared, thread-safe instance |
| Thread safety | Guaranteed per instance | Developer's responsibility |
| Throttling | Pool size | Explicit bulkhead (e.g. MP Fault Tolerance) |
| Transactions | CMT default REQUIRED | @Transactional explicitly |
10· Interview Q&A
What is a stateless session bean?
A stateless session bean, marked @Stateless, is an EJB that keeps no conversational state for a particular client between method calls. Because every instance is equivalent, the container keeps a pool of them, gives any free instance to each incoming call and returns it to the pool afterwards. Each instance serves only one thread at a time, so its code needs no synchronisation. Every call goes through a container proxy that applies declarative services, most importantly container-managed transactions, which default to REQUIRED, plus security checks and interceptors. That combination is why stateless beans became the standard transactional service layer in Jakarta EE applications, for example an OrderService with a place method.
Why does the container pool stateless beans?
Pooling follows from two guarantees: instances are interchangeable, and the container promises that each instance is used by only one thread at a time. A pool lets the container keep the second promise without creating and initialising a new instance, with its injection and @PostConstruct work, for every call. It also provides a natural concurrency limit: with a bounded pool of 32, at most 32 calls run in that bean at once and the rest wait up to the acquisition timeout. That makes the pool a bulkhead protecting databases and external systems. The specification does not mandate pooling, so the exact behaviour and limits are vendor configuration.
Can a stateless bean have instance fields?
Yes, but only for things that are not tied to a particular client. Injected services, an EntityManager, a client for an external API, a logger and immutable configuration are all fine, because every caller can safely use them, and single-threaded access per instance means even helpers that are not thread-safe work correctly. What must never live in a field is data belonging to one caller, such as a cart, a user ID or a partly built request, because the next call on that instance may come from a different user, who would then see it. Per-call scratch data is technically safe if reset every call, but local variables are clearer and cannot leak.
What happens to an instance when a system exception is thrown?
A system exception is an unchecked exception, or a RemoteException, that is not annotated @ApplicationException. When a business method throws one, the container logs it, marks the current transaction for rollback, or rolls back the transaction it started itself, and discards the instance because its state may be corrupt; @PreDestroy is not called. The client receives an EJBException, or an EJBTransactionRolledbackException if it was running in the caller's transaction, wrapping the cause. Application exceptions behave differently: the instance is kept and the transaction commits unless the exception is marked rollback=true. Watching the discarded-instance count is therefore a cheap way to spot unexpected runtime failures in production.
How would you size a stateless bean pool?
I size it from the capacity of whatever the bean calls, not from the number of HTTP threads. If each business method holds a database connection, the pool should be at or slightly below the data source maximum, so callers wait at the bean with a clear timeout instead of failing on connection acquisition. If it calls an external API limited to 20 concurrent requests, the pool should not exceed 20. Little's law gives a starting point: required concurrency is arrival rate times latency, so 200 calls per second at 100 milliseconds needs about 20 instances. Then load-test, watching wait time, acquisition timeouts and downstream saturation, and adjust.
When would you replace stateless EJBs with CDI beans?
When the organisation is moving to lighter runtimes, such as Core or Web Profile servers, containers or MicroProfile microservices, and the EJB features in use have direct CDI equivalents. An @ApplicationScoped bean with @Transactional gives the same transaction boundaries, and a MicroProfile Fault Tolerance @Bulkhead replaces the throttling the pool provided. The migration is not purely mechanical: CDI beans are shared across threads, so fields must be thread-safe; exceptions are no longer wrapped in EJBException; and remote interfaces, timers and asynchronous methods need their own replacements. I would migrate facade by facade, with integration tests verifying commit and rollback behaviour, and leave stable EJBs alone where there is no runtime driver.