JAX-RS — Jakarta RESTful Web Services

The standard specification for REST APIs in Jakarta EE — resource classes, parameter binding, exception mappers, filters, and how JAX-RS compares to Spring MVC in real projects

← Back to Index

What is JAX-RS — and Why Does It Matter in 2025?

JAX-RS (Jakarta RESTful Web Services) is a specification — a set of annotations and interfaces in jakarta.ws.rs.* that standardizes how to map HTTP requests to Java methods. Like JPA, it has no runtime code of its own: you need a provider implementation at runtime. Jersey (the RI), RESTEasy (WildFly's bundled impl), and Apache CXF are the three providers still in active use.

Before JAX-RS (2008), every framework had its own proprietary way to handle REST: raw Servlets with switch statements over request.getMethod(), framework-specific annotations, or vendor-specific XML configuration. JAX-RS gave Jakarta EE a vendor-neutral model that still ships inside every certified application server today.

Without JAX-RS (raw Servlet)With JAX-RS
protected void doGet(HttpServletRequest req, HttpServletResponse res) {
    String id = req.getParameter("id");
    if (id == null) { res.sendError(400); return; }
    Product p = service.findById(Long.parseLong(id));
    if (p == null) { res.sendError(404); return; }
    res.setContentType("application/json");
    // manual JSON serialization...
}
@GET
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public Product getProduct(
        @PathParam("id") Long id) {
    return service.findById(id);  // 404 on null via mapper
}
JAX-RS vs Spring MVC — why this page still matters if you write Spring Boot
AspectJAX-RSSpring MVC / Spring REST
Origin Jakarta EE specification (vendor-neutral) Spring Framework (proprietary, but dominant)
Annotation set @Path, @GET, @Produces, @QueryParam @RequestMapping, @GetMapping, @RequestParam
Where you'll find it WildFly, Payara, WebSphere, Quarkus — any Jakarta EE server Spring Boot, embedded Tomcat/Undertow
JSON binding JSON-B (spec) or Jackson (provider-plugged) Jackson (default, auto-configured)
Exception handling ExceptionMapper<T> implementations @ExceptionHandler / @ControllerAdvice
Greenfield choice in 2025 Quarkus (excellent JAX-RS via RESTEasy Reactive), Helidon Spring Boot (still dominant in enterprise)

Spring Boot is the more common choice for new projects, but JAX-RS is the standard in a large body of existing Jakarta EE systems and is the native model in Quarkus, which is gaining traction for cloud-native workloads. Understanding JAX-RS also makes Spring MVC trivial by comparison — the mental model is nearly identical, the annotations just have different names.

Bootstrapping a JAX-RS Application

The Application subclass — entry point in Jakarta EE

import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;

@ApplicationPath("/api")
public class RestApplication extends Application {
    // Empty body: the container discovers all @Provider and resource
    // classes annotated with @Path on the classpath automatically.
    // Alternatively, override getClasses() to register explicitly.
}

A complete, production-shaped resource class

import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;

@Path("/users")
@Produces(MediaType.APPLICATION_JSON)   // class-level default
@Consumes(MediaType.APPLICATION_JSON)
public class UserResource {

    @Inject
    private UserService userService;

    @GET
    public List<UserDto> listUsers(
            @QueryParam("page") @DefaultValue("0") int page,
            @QueryParam("size") @DefaultValue("20") int size) {
        return userService.findAll(page, size);
    }

    @GET
    @Path("/{id}")
    public UserDto getUser(@PathParam("id") Long id) {
        return userService.findById(id)
            .orElseThrow(() -> new NotFoundException("User not found: " + id));
    }

    @POST
    public Response createUser(@Valid CreateUserRequest request,
                                @Context UriInfo uriInfo) {
        UserDto created = userService.create(request);
        URI location = uriInfo.getAbsolutePathBuilder()
                              .path(String.valueOf(created.id()))
                              .build();
        return Response.created(location).entity(created).build();
    }

    @PUT
    @Path("/{id}")
    public UserDto updateUser(@PathParam("id") Long id,
                              @Valid UpdateUserRequest request) {
        return userService.update(id, request);
    }

    @DELETE
    @Path("/{id}")
    public Response deleteUser(@PathParam("id") Long id) {
        userService.delete(id);
        return Response.noContent().build();
    }
}

// DTOs as records — immutable, no boilerplate, JSON-B/Jackson compatible
public record UserDto(Long id, String username, String email) {}
public record CreateUserRequest(@NotBlank String username,
                               @Email String email) {}
public record UpdateUserRequest(@Size(max = 50) String username) {}
Resource classes are instantiated per-request by default — never rely on field state

Unlike Spring's singleton controllers, a JAX-RS resource class receives a new instance per HTTP request. Any state held in fields (counters, caches, partially built objects) is silently lost between calls. Stateful logic belongs in injected CDI beans with appropriate scope (@ApplicationScoped, @RequestScoped, etc.), not in the resource class itself. Some providers (Jersey, CXF) allow @Singleton on the resource class to opt out of this default — but then you must handle concurrency yourself.

Parameter Injection — All the Ways to Bind Incoming Data

JAX-RS can inject virtually anything from the request into method parameters or fields using annotations. Every injection annotation handles type conversion from the raw String to the Java type automatically, or throws a 400 Bad Request if conversion fails.

@Path("/products")
public class ProductResource {

    // @PathParam — from the URL segment declared in @Path
    // GET /api/products/electronics/42
    @GET
    @Path("/{category}/{id}")
    public ProductDto getByCategory(
            @PathParam("category") String category,
            @PathParam("id") Long id) { ... }

    // @QueryParam — from the URL query string
    // GET /api/products?q=laptop&minPrice=500&inStock=true&page=2&size=20
    @GET
    public List<ProductDto> search(
            @QueryParam("q") String query,
            @QueryParam("minPrice") @DefaultValue("0")    BigDecimal minPrice,
            @QueryParam("inStock") @DefaultValue("false") boolean inStock,
            @QueryParam("page")    @DefaultValue("0")     int page,
            @QueryParam("size")    @DefaultValue("20")    int size) { ... }

    // @HeaderParam — from a specific HTTP header
    // GET /api/products  X-Tenant-Id: acme
    @GET
    @Path("/tenant")
    public List<ProductDto> getByTenant(
            @HeaderParam("X-Tenant-Id") String tenantId) { ... }

    // @CookieParam — from a specific cookie
    @GET
    @Path("/personalized")
    public List<ProductDto> getPersonalized(
            @CookieParam("session_id") String sessionId) { ... }

    // @Context — inject JAX-RS context objects (request, response, uriInfo...)
    @POST
    public Response create(CreateProductRequest request,
                            @Context UriInfo uriInfo,
                            @Context HttpHeaders headers,
                            @Context SecurityContext security) { ... }
}
AnnotationSourceNull if missing?
@PathParamURL path segment (/{id})Only if the regex allows the segment to be absent — unusual. If the path matches, the value is always present
@QueryParamURL query string (?key=value)Yes — null for reference types, 0/false for primitives unless @DefaultValue is set
@HeaderParamHTTP request headerYes — null if the header is absent
@CookieParamHTTP cookieYes — null if the cookie is absent
@FormParamForm field (application/x-www-form-urlencoded)Yes — null if the field is absent
@BeanParamGroups multiple injection annotations into one objectObject is always created; individual fields follow their own annotation rules
@ContextJAX-RS context objects (UriInfo, HttpHeaders, Request, SecurityContext)Never null — always injected by the container

@BeanParam — grouping parameters to avoid long parameter lists

// Instead of 5 @QueryParam parameters on the method signature:
public class ProductSearchParams {
    @QueryParam("q")          public String query;
    @QueryParam("minPrice")   @DefaultValue("0")  public BigDecimal minPrice;
    @QueryParam("page")       @DefaultValue("0")  public int page;
    @QueryParam("size")       @DefaultValue("20") public int size;
    @HeaderParam("Accept-Language")             public String locale;
}

@GET
public List<ProductDto> search(@BeanParam ProductSearchParams params) {
    return productService.search(params);
}

HTTP Methods — Semantics, Status Codes, and Common Mistakes

MethodPurposeRequest bodyIdempotentSafeCorrect success code
GETRetrieve resource or collectionNoYesYes200 OK
POSTCreate resource (or trigger an action)YesNoNo201 Created + Location header
PUTReplace resource completelyYesYesNo200 OK (or 204 if no body returned)
PATCHPartial updateYesNo (usually)No200 OK
DELETERemove resourceNoYesNo204 No Content
HEADLike GET but response body stripped — used to check existence/headersNoYesYes200 OK
OPTIONSReturns supported methods — CORS preflightNoYesYes200 OK
Idempotent does not mean "no side effects" — it means "same result if called N times"

DELETE /users/42 is idempotent: the first call removes the user, the second call finds nothing and should return 204 or 404 — the resource ends up absent either way. Returning a 500 on the second call because the row no longer exists in the database is incorrect. The convention is 204 for both cases (or 404 if your API contract says "absence is an error"). The distinction matters because proxies, load balancers, and clients retry idempotent requests on timeout — if your handler isn't truly idempotent, a retry causes a duplicate operation.

Response builder — the production shape

// POST: 201 Created with Location header pointing to the new resource
@POST
public Response createOrder(@Valid CreateOrderRequest request,
                             @Context UriInfo uriInfo) {
    OrderDto created = orderService.create(request);
    URI location = uriInfo.getAbsolutePathBuilder()
                          .path(String.valueOf(created.id()))
                          .build();
    return Response.created(location)  // sets status 201 + Location header
                   .entity(created)
                   .build();
}

// DELETE: 204 No Content — success with no response body
@DELETE
@Path("/{id}")
public Response deleteOrder(@PathParam("id") Long id) {
    orderService.delete(id);
    return Response.noContent().build();
}

// GET that returns null without a mapper → serializes as HTTP 200 with JSON null body.
// Always handle the not-found case explicitly:
@GET
@Path("/{id}")
public OrderDto getOrder(@PathParam("id") Long id) {
    return orderService.findById(id)
        .orElseThrow(() -> new NotFoundException("Order not found: " + id));
    // NotFoundException is a WebApplicationException subtype that
    // JAX-RS converts to 404 automatically — no custom mapper needed.
}

Exception Handling — Built-in Exceptions and Custom Mappers

Built-in WebApplicationException hierarchy

JAX-RS ships ready-made exception types for the most common HTTP error statuses. Throwing any of these inside a resource method causes the runtime to respond with the corresponding status code — no mapper needed:

// These are WebApplicationException subtypes — throw directly from resource methods
throw new NotFoundException("Order " + id + " not found");         // 404
throw new BadRequestException("Invalid payload: " + reason);       // 400
throw new NotAuthorizedException("Bearer");                        // 401 (requires challenge)
throw new ForbiddenException("Insufficient permissions");           // 403
throw new NotAllowedException("GET", "POST");                      // 405 — allowed methods required
throw new NotAcceptableException("Only application/json supported"); // 406
throw new ServiceUnavailableException(300L);                         // 503 with Retry-After seconds
throw new InternalServerErrorException("Unexpected error");          // 500 (but prefer ExceptionMapper)

Custom ExceptionMapper — the right pattern for domain exceptions

Service-layer exceptions should not be WebApplicationException subclasses — the service layer should not know about HTTP. An ExceptionMapper bridges between domain exceptions and HTTP responses without coupling those layers:

// Domain exception — knows nothing about HTTP
public class OrderNotFoundException extends RuntimeException {
    private final Long orderId;
    public OrderNotFoundException(Long orderId) {
        super("Order not found: " + orderId);
        this.orderId = orderId;
    }
    public Long getOrderId() { return orderId; }
}

// Error response body — a record is perfect here
public record ErrorResponse(String error, String message, String path) {}

// The mapper: discovered automatically thanks to @Provider
@Provider
public class OrderNotFoundMapper
        implements ExceptionMapper<OrderNotFoundException> {

    @Context
    private UriInfo uriInfo;

    @Override
    public Response toResponse(OrderNotFoundException ex) {
        return Response.status(Response.Status.NOT_FOUND)
                       .entity(new ErrorResponse(
                           "NOT_FOUND",
                           ex.getMessage(),
                           uriInfo.getPath()))
                       .build();
    }
}

// A single catch-all mapper for unhandled RuntimeExceptions
@Provider
public class GlobalExceptionMapper
        implements ExceptionMapper<Exception> {

    @Override
    public Response toResponse(Exception ex) {
        // Never leak internal exception details to clients in production
        return Response.serverError()
                       .entity(new ErrorResponse(
                           "INTERNAL_ERROR",
                           "An unexpected error occurred",
                           null))
                       .build();
    }
}
A ExceptionMapper<Exception> intercepts JAX-RS's own WebApplicationException — this is usually wrong

If you register a mapper for the root Exception type, it also catches every WebApplicationException (including NotFoundException, ForbiddenException, etc.) and overrides their status codes with whatever your mapper returns. The fix is to check whether the caught exception is a WebApplicationException and pass it through:

@Override
public Response toResponse(Exception ex) {
    if (ex instanceof WebApplicationException wae) {
        return wae.getResponse();   // let JAX-RS handle its own exceptions
    }
    return Response.serverError()
                   .entity(new ErrorResponse("INTERNAL_ERROR", "...", null))
                   .build();
}

Filters and Interceptors — Cross-Cutting Concerns

JAX-RS has two distinct extension points for cross-cutting concerns: filters operate on request/response metadata (headers, status, URI); interceptors operate on the message body (wrapping MessageBodyReader/MessageBodyWriter during (de)serialization). Most real-world needs are met by filters.

Request filter — authentication

@Provider
@Priority(Priorities.AUTHENTICATION)  // runs before AUTHORIZATION filters
public class JwtAuthFilter implements ContainerRequestFilter {

    @Inject
    private JwtValidator jwtValidator;

    @Override
    public void filter(ContainerRequestContext ctx) {
        String header = ctx.getHeaderString(HttpHeaders.AUTHORIZATION);
        if (header == null || !header.startsWith("Bearer ")) {
            ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED)
                                  .header("WWW-Authenticate", "Bearer")
                                  .build());
            return;
        }
        String token = header.substring(7);
        try {
            UserPrincipal principal = jwtValidator.validate(token);
            // Install a SecurityContext so @Context SecurityContext works downstream
            ctx.setSecurityContext(new JwtSecurityContext(principal));
        } catch (InvalidTokenException e) {
            ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
        }
    }
}

Response filter — CORS

@Provider
public class CorsFilter implements ContainerResponseFilter {

    @Override
    public void filter(ContainerRequestContext req,
                      ContainerResponseContext res) {
        var headers = res.getHeaders();
        headers.add("Access-Control-Allow-Origin",  "https://myapp.example.com");
        headers.add("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
        headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type");
        headers.add("Access-Control-Max-Age",       "86400");
    }
}
Access-Control-Allow-Origin: * and credentials don't mix

If the browser sends a request with cookies or an Authorization header (a "credentialed request"), the wildcard * origin is rejected by every modern browser. You must echo back the specific Origin header value from the request, add Access-Control-Allow-Credentials: true, and whitelist only your known origins server-side. The wildcard is only safe for public, anonymous read APIs.

@NameBinding — applying filters to specific endpoints only

// 1. Declare a name-binding annotation
@NameBinding
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Authenticated {}

// 2. Apply the annotation to the filter
@Provider
@Authenticated
public class JwtAuthFilter implements ContainerRequestFilter { ... }

// 3. Tag only the endpoints (or whole resource class) that need it
@Path("/orders")
@Authenticated   // every method in this class now goes through JwtAuthFilter
public class OrderResource { ... }

@Path("/health")
public class HealthResource { ... }  // not tagged → filter never runs here

Content Negotiation and JSON Serialization

JAX-RS uses the Accept and Content-Type headers to select the right MessageBodyWriter/MessageBodyReader for serialization. @Produces declares what the endpoint can return; @Consumes declares what it can accept. If no matching provider is found, JAX-RS returns 406 Not Acceptable or 415 Unsupported Media Type automatically.

@GET
@Path("/{id}")
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public ProductDto getProduct(@PathParam("id") Long id) {
    // Sends JSON if client sends: Accept: application/json
    // Sends XML if client sends:  Accept: application/xml
    // Returns 406 if client sends: Accept: text/csv
    return productService.findById(id);
}

JSON-B configuration — controlling serialization

// JSON-B (Jakarta JSON Binding) is the spec-standard JSON provider
// Customize it by providing a Jsonb instance or using annotations on the DTO

public record ProductDto(
    Long id,

    @JsonbProperty("product_name")  // serializes as "product_name" not "name"
    String name,

    @JsonbNumberFormat("#0.00")
    BigDecimal price,

    @JsonbDateFormat("yyyy-MM-dd")
    LocalDate createdOn,

    @JsonbTransient  // excluded from serialization entirely
    String internalCode
) {}

Bean Validation Integration

JAX-RS integrates with Jakarta Bean Validation out of the box on any certified Jakarta EE container. Adding @Valid to a parameter or return type triggers constraint validation automatically — no manual validation code needed. Validation failures are caught by a built-in ExceptionMapper for ConstraintViolationException (the container usually provides one; register a custom one to control the response shape).

public record CreateProductRequest(
    @NotBlank(message = "Name is required")
    @Size(max = 100)
    String name,

    @NotNull
    @DecimalMin("0.01")
    BigDecimal price,

    @NotBlank
    @Pattern(regexp = "[A-Z]{2,5}")
    String sku
) {}

@POST
public Response create(@Valid CreateProductRequest request) {
    // If any constraint fails, JAX-RS intercepts and returns 400
    // before this line is ever reached
    return Response.status(201).entity(productService.create(request)).build();
}

// Register a custom mapper to control the error response shape:
@Provider
public class ValidationExceptionMapper
        implements ExceptionMapper<ConstraintViolationException> {

    @Override
    public Response toResponse(ConstraintViolationException ex) {
        Map<String, String> errors = ex.getConstraintViolations().stream()
            .collect(Collectors.toMap(
                cv -> cv.getPropertyPath().toString(),
                cv -> cv.getMessage()
            ));
        return Response.status(Response.Status.BAD_REQUEST)
                       .entity(errors)
                       .build();
    }
}

Async Processing — @Suspended and CompletionStage

JAX-RS offers two async models. The @Suspended model (JAX-RS 2.0) suspends the request-handling thread and resumes the response from any other thread. The CompletionStage return type (JAX-RS 2.1) is the cleaner modern approach — no thread management, integrates with virtual threads in Java 21.

// JAX-RS 2.1+: return CompletionStage — clean, no manual thread management
@GET
@Path("/reports/{id}")
public CompletionStage<Response> getReport(@PathParam("id") Long id) {
    return reportService.buildAsync(id)
                       .thenApply(Response::ok)
                       .thenApply(Response.ResponseBuilder::build);
}

// JAX-RS 2.0: @Suspended — more control, more verbosity
@GET
@Path("/legacy-reports/{id}")
public void getLegacyReport(@PathParam("id") Long id,
                            @Suspended AsyncResponse ar) {
    ar.setTimeout(10, TimeUnit.SECONDS);
    ar.setTimeoutHandler(resp -> resp.resume(
        Response.status(Response.Status.SERVICE_UNAVAILABLE).build()));

    reportService.buildAsync(id)
                 .thenAccept(report -> ar.resume(Response.ok(report).build()))
                 .exceptionally(ex -> { ar.resume(ex); return null; });
}

// Important: new Thread(() -> ...).start() in a resource method is wrong.
// Use @Asynchronous on the EJB service, or an injected ManagedExecutorService,
// so the container manages the thread lifecycle (transactions, security context).
Never spawn raw threads from inside a JAX-RS resource method

The original file in this project showed new Thread(() -> ...).start() inside an async resource — this is wrong in a managed environment. Raw threads bypass the container's thread pool management, don't inherit the current transaction context or security context, can't use @PersistenceContext injection reliably, and may exhaust file descriptors under load. Use a ManagedExecutorService (injected via @Resource), @Asynchronous EJB methods, or return a CompletionStage from a properly managed async service.

Best Practices and Common Pitfalls

✅ Do

  • Always return Response from @POST with a Location header pointing to the new resource — this is the correct REST contract, and clients depend on it to retrieve the created entity
  • Use @NameBinding to scope filters to specific endpoints instead of running them globally — authentication filters on a health check endpoint are unnecessary overhead
  • Use DTOs (records) as request and response types — never expose @Entity classes at the boundary. Bidirectional JPA relationships cause infinite serialization loops
  • Register a ExceptionMapper<ConstraintViolationException> to control the validation error response shape — the default varies by provider and is usually not API-quality
  • Set explicit CORS origins, never * when credentials are involved
  • Use CompletionStage return types for async endpoints instead of @Suspended — cleaner code, better integration with modern Java concurrency

❌ Don't

  • Don't return null from resource methods — it serializes as HTTP 204 or an empty body, not a 404. Throw NotFoundException or use an Optional-aware pattern
  • Don't put business logic in resource classes — they are HTTP adapters only. All validation beyond format checking, all transactions, and all domain rules belong in a service layer
  • Don't register an ExceptionMapper<Exception> without first checking for and passing through WebApplicationException — otherwise your 404s become 500s
  • Don't spawn raw Thread objects from resource methods — use container-managed threading
  • Don't rely on resource-class field state between requests — resource instances are per-request, not singletons
  • Don't manually inline HTTP status codes as integers (Response.status(404)) — use Response.Status constants for readability and to avoid typos

Interview Questions

🎓 Junior level

Q: What is the difference between @PathParam and @QueryParam?
@PathParam binds a variable segment from the URL path declared in @Path (e.g. /{id}@PathParam("id")). @QueryParam binds a key-value pair from the URL query string (e.g. ?page=2@QueryParam("page")). Path params identify a specific resource; query params typically filter or paginate a collection.

Q: What HTTP status code should a successful @POST that creates a resource return?
201 Created, along with a Location header containing the URI of the newly created resource. Returning 200 OK is technically wrong because 200 means "here is the state of an existing resource" while 201 means "I created something new and here is where to find it."

Q: What does @Produces do?
It declares the media types the endpoint can return (e.g. application/json, application/xml). The JAX-RS runtime matches the client's Accept header against the declared types to pick the right MessageBodyWriter for serialization. If no matching type can be found, the runtime returns 406 Not Acceptable automatically.

🔥 Senior level

Q: A @GET endpoint returns null when the entity is not found. What actually happens, and what should happen instead?
When a JAX-RS method with a non-Response return type returns null, the runtime maps it to a 204 No Content response with an empty body — not a 404. This is wrong for a GET on a single resource where absence means "not found". The correct approach is to throw NotFoundException (a WebApplicationException subtype that produces a 404) or to use a service return type of Optional<T> and call .orElseThrow(() -> new NotFoundException(...)). Using a custom ExceptionMapper keeps the 404 response shape consistent with all other error responses in the API.

Q: Why is registering a global ExceptionMapper<Exception> dangerous, and how do you fix it?
WebApplicationException is a subclass of RuntimeException which is a subclass of Exception. A mapper for Exception therefore catches every NotFoundException, ForbiddenException, and BadRequestException that resource methods and the framework itself throw, replacing their carefully assigned status codes with whatever your mapper returns — usually 500. The fix is to check if (ex instanceof WebApplicationException wae) return wae.getResponse(); at the top of toResponse(), delegating all JAX-RS exceptions back to the framework's default handling.

Q: What is the difference between a JAX-RS filter and an interceptor, and when do you use each?
Filters implement ContainerRequestFilter or ContainerResponseFilter and operate on request/response metadata: headers, URI, HTTP method, status code, and whether to abort a request before the resource method runs. Interceptors implement ReaderInterceptor or WriterInterceptor and wrap the reading or writing of the message body — they are invoked during deserialization (request body → Java object) and serialization (Java object → response body). Typical filter use cases: authentication, CORS, logging, rate limiting. Typical interceptor use cases: request body decryption, response compression, audit logging of full request/response payloads.

Q: JAX-RS resource classes are per-request by default. What does this mean for concurrent access, and when would you change it?
Each incoming HTTP request gets its own instance of the resource class, so fields on the resource class are never shared between concurrent requests — there is no concurrency problem on the resource class itself. The trade-off is that instantiation and injection happen on every request. Annotating the resource class with @Singleton (Jersey) or registering it as a singleton creates one shared instance for all requests, eliminating per-request instantiation overhead at the cost of requiring thread-safe handling for any mutable state. In practice, resource classes should hold no mutable state at all — only injected services — making the per-request default safe and the singleton alternative irrelevant.