Handling Requests with HttpServlet
In Phase 2 you saw the container create one instance of your servlet and feed every request through a
single service method. That service method is where the real work happens — but you almost never
override it directly. Instead you extend HttpServlet, which has already done the tedious part for you:
it looks at the HTTP method on the incoming request and routes it to a method named after that verb.
📝 The mental model for this whole phase: an HTTP request is two things glued together — a method
(GET, POST, ...) and a payload (the URL, headers, and body). HttpServlet splits those apart for you.
The method picks which of your functions runs; the payload arrives as a HttpServletRequest object you
read from. You write your answer into a HttpServletResponse object. Read request, write response. That's
the entire job. Everything a web framework does is a fancier version of exactly this.
If "method," "header," "status code," and "body" feel fuzzy, spend ten minutes in HTTP & JSON: the API Building Blocks first — this phase assumes you can read a raw request and response.
HttpServlet & the doXxx methods
HttpServlet gives you one method per HTTP verb. You override the one(s) you care about; the container
calls the right one based on the request line:
| HTTP request | Method called |
|---|---|
GET /users |
doGet |
POST /users |
doPost |
PUT /users/7 |
doPut |
DELETE /users/7 |
doDelete |
Here's a servlet that handles both reading and creating — GET to list, POST to add:
What just happened: HttpServlet's built-in service method inspected the request line. A GET
landed in doGet; a POST landed in doPost. You never wrote a single if (method.equals("POST")) —
inheritance did the dispatch. Any verb you don't override (say DELETE) gets a polite automatic 405 Method Not Allowed from the parent class, which is exactly what you want.
💡 If you ever override a doXxx method, don't call super.doGet(...) unless you mean it — the parent's
default is to return that 405, which will clobber your response.
Reading the request (HttpServletRequest)
The HttpServletRequest is the whole incoming message turned into an object. The pieces you'll reach for
constantly:
| You want… | Call |
|---|---|
| A query string or form field | req.getParameter("name") |
| A request header | req.getHeader("Content-Type") |
| The path after the servlet's mapping | req.getPathInfo() |
| The raw body (for JSON) | req.getReader() or req.getInputStream() |
getParameter is the workhorse. It pulls from the query string for a GET and from a URL-encoded form
body for a POST — same call, the container figures out where to look:
protected void
throws IOException
What just happened: the container parsed ?name=Ada off the URL and handed you the value through
getParameter. Note it returns null when the param is absent — there's no exception, so you check for
it yourself. getHeader reads any header by name, case-insensitively.
For a JSON API, the data doesn't arrive as named params — it's a raw body you read as a stream of text:
protected void
throws IOException
What just happened: getReader() gave you the request body as character text, which you drained
line by line into a string. At this point you have raw JSON — a real app would hand that string to a
parser (more on that below). The try-with-resources block closes the reader for you.
⚠️ Read the body once. getParameter on a POST quietly consumes the form body to find its values,
and getReader/getInputStream consume the body too. You can't have both, and you can't read the stream
twice — the second read comes back empty. Decide up front: form params or raw body, not both, and read
the body a single time.
Writing the response (HttpServletResponse)
The response object is your outgoing message, and you build it in a specific order: status and headers first, body last. Once you start writing the body, the status line and headers have already been sent, so setting them afterward does nothing.
| You want… | Call |
|---|---|
| Set the status code | resp.setStatus(201) |
| Set the content type | resp.setContentType("application/json") |
| Set any header | resp.setHeader("Cache-Control", "no-store") |
| Write the body | resp.getWriter().write(...) |
Here's a servlet returning JSON, assembled entirely by hand:
protected void
throws IOException
What just happened: you set the status, declared the content type so the client knows to parse it as JSON, then wrote the body string through the writer. Notice you built the JSON by hand-concatenating a string with escaped quotes — clumsy and error-prone, but it shows there's no magic. It's just text going down a socket.
The wire result of that code looks like this:
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
{"id":7,"name":"Ada","role":"admin"}
💡 In real code you would never hand-build that string. You'd hand an object to a JSON library — Jackson or Gson — and let it serialize:
// What you'd actually do: let Jackson turn an object into JSON text
ObjectMapper mapper ;
String json ; // -> {"id":7,"name":"Ada",...}
resp.;
resp..;
What just happened: the ObjectMapper walked the fields of your user object and produced the JSON
text for you — same bytes as the hand-built string, none of the escaping. This is the one part of raw
servlet work that frameworks really do save you from. Seeing it bare once is the point; you won't do it
this way again.
By hand vs the framework
Step back and look at what all that code actually did, in order:
- The container picked
doGetordoPostbased on the HTTP method. - You pulled values out of the request — params, headers, body.
- You ran your logic.
- You serialized a result and wrote it back with a status code.
💡 That list is what a Spring controller does — the framework has just hidden each step behind an annotation:
// Spring MVC — the same four steps, annotated
public User
What just happened: @GetMapping is doing the doGet-style dispatch. @PathVariable and
@RequestParam are doing your getParameter/getPathInfo reads. Returning a User object instead of
writing a string is the framework calling Jackson and getWriter().write(...) for you. Same servlet
machinery underneath — @GetMapping is convenience over doGet, nothing more. The servlet is the
unglamorous truth beneath the annotations; once you've seen it, the annotations stop being magic and start
being shorthand.
The request and response are per-call
Here's the thread-safety thread from Phase 2, finally tied off. Remember: the container keeps one
instance of your servlet and runs doGet/doPost on it from many threads at once. So how is the code
above safe?
💡 Because the req and resp objects are created fresh by the container for every single request and
passed in as parameters. Thread A's doGet gets thread A's request; thread B's doGet gets a completely
separate request object. The shared thing (the servlet instance) holds no per-request data; the
per-request things (the request and response) aren't shared. That's the whole trick.
What just happened: hits is an instance field on the one shared servlet, so two threads incrementing
it at once will trample each other and lose counts — a classic race. But anything you derive from req
is yours alone, because req was minted for this one call. The rule that falls out: keep per-request
state in local variables and in the request object, never in servlet fields. Method-local variables
live on each thread's own stack, so they can't collide.
This per-call request object is also what makes routing possible — the next phase reads req.getPathInfo()
to decide which handler should run, letting a single servlet dispatch to many. That's the
front-controller pattern, and it's where DispatcherServlet's secret lives.
Recap
HttpServletroutes each request to adoXxxmethod by HTTP verb — overridedoGet,doPost, etc.; unhandled verbs auto-return405.- Read the request with
getParameter(query + form),getHeader,getPathInfo, andgetReader/getInputStreamfor a raw JSON body. - You can read the body once:
getParameteron a POST consumes the form body, and the input stream/reader can't be re-read. - Build the response status and headers before the body:
setStatus,setContentType,setHeader, thengetWriter().write(...). - Hand-writing JSON is the raw truth; real apps let Jackson/Gson serialize — and
@GetMapping/@RequestParam/ returning an object is exactly these steps with annotations on top. - The request and response are created per request, so they're safe to use even though the servlet instance is shared — keep state in locals, not fields.
Quick check
[
{
"q": "A POST request arrives and your servlet only overrides doGet. What happens?",
"choices": ["doGet runs anyway", "The container returns 405 Method Not Allowed", "The request hangs forever"],
"answer": 1,
"explain": "HttpServlet's default doPost returns 405 Method Not Allowed, since you didn't override it."
},
{
"q": "Why is it unsafe to call getParameter and then read the body with getReader on the same POST?",
"choices": ["getParameter is slower", "getParameter can consume the form body, so the reader comes back empty", "getReader only works on GET requests"],
"answer": 1,
"explain": "Reading params on a POST can consume the body; the body can only be read once, so the later read finds nothing."
},
{
"q": "One servlet instance serves many threads. What makes the request/response objects safe to use?",
"choices": ["They are synchronized with locks", "They are created fresh by the container for each request and passed in", "They are stored in static fields"],
"answer": 1,
"explain": "The container mints a new request and response per call and passes them as parameters, so no two threads share them."
}
]
← Phase 2: The Servlet Container & Lifecycle · Guide overview · Phase 4: Mapping & the Front-Controller Pattern →
Check your understanding
1. A POST request arrives and your servlet only overrides doGet. What happens?
2. Why is it unsafe to call getParameter and then read the body with getReader on the same POST?
3. One servlet instance serves many threads. What makes the request/response objects safe to use?