- Overview
- Architecture
- Request Lifecycle
- Concurrency Model
- Routing
- Static File Serving
- Middleware
- Error Handling
- Getting Started
- Running with Docker
- Testing
- Project Structure
- Known Limitations
This is a raw-socket HTTP/1.1 server written in Java using only the standard library — ServerSocket, Socket, and java.util.concurrent. There's no Spring Boot, no servlet container, and no third-party HTTP library underneath it. Every layer — accepting TCP connections, parsing the request line and headers, routing, and writing the response back onto the wire — is hand-built.
The goal of this project is to understand what frameworks like Spring normally abstract away: connection handling, protocol parsing, thread pool sizing, and graceful shutdown.
What it does:
- Accepts TCP connections on a fixed port and hands each one to a bounded thread pool
- Parses raw HTTP/1.1 requests (request line, headers, query string, body) by hand
- Runs each request through a small middleware chain before routing
- Dispatches to registered handlers by
METHOD + path, falling back to static file serving for unmatchedGETrequests - Serves static files from a fixed webroot with directory traversal protection
- Converts any unhandled exception into a proper HTTP error response instead of crashing the connection
- Shuts down gracefully on
SIGTERM/Ctrl+C, draining in-flight requests before closing
---
Mainbinds aServerSocketon port9090and loops onaccept()- Each accepted
Socketis wrapped in aClientHandlerand submitted to theThreadPoolExecutor HttpRequestParserreads the request line, headers, and (ifContent-Lengthis present) the body from the socket'sInputStream- The resulting
HttpRequestis passed through the middleware chain:Loggingprints method, path, version, and key headers, then passes throughValidationrejects unsupported methods, malformed paths, non-HTTP/1.1requests, and POSTs with an empty body — any rejection short-circuits the chain with a400
Routerlooks upMETHOD + pathin its handler map; if nothing matches and the method isGET, it falls back toStaticFileHandler; otherwise it returns a404- The handler builds an
HttpResponse ResponseWriterserializes the status line, headers (computingContent-Lengthfrom the UTF-8 byte length of the body), and body, then flushes it to the socket- The connection is closed — there's no keep-alive, so every request is a fresh TCP handshake
BlockingDeque<Runnable> queue = new LinkedBlockingDeque<>(15);
ThreadPoolExecutor threadPoolExecutor =
new ThreadPoolExecutor(5, 5, 1000, TimeUnit.SECONDS, queue);Five worker threads, backed by a bounded queue of 15 pending connections — this is a deliberate choice over an unbounded pool or an unbounded queue: it caps both how many requests run concurrently and how many can pile up waiting, rather than letting either grow without limit under load.
Graceful shutdown is wired to the JVM shutdown hook in Main:
Runtime.getRuntime().addShutdownHook(new Thread(() ->
ServerLifecycle.shutdown(serverSocket, threadPoolExecutor)
));On shutdown, ServerLifecycle closes the listening socket first (so no new connections are accepted), then calls executor.shutdown() to let in-flight requests finish, waiting up to 5 seconds before forcing a shutdownNow().
Router maps "METHOD /path" keys to handlers and is seeded with two example resources on startup:
| Method | Path | Handler |
|---|---|---|
| GET | /hello |
HelloHandler |
| POST | /hello |
HelloHandler |
| GET | /users |
UsersHandler |
| POST | /users |
UsersHandler |
register(method, path, handler) and unregister(method, path) allow adding or removing routes at runtime. Any unmatched GET request falls through to StaticFileHandler before returning a 404; unmatched non-GET requests go straight to 404.
HelloHandler and UsersHandler are intentionally simple example endpoints backed by static in-memory data (SeedData) — they demonstrate the routing and handler contract rather than real business logic.
StaticFileHandler serves files from src/main/resources/static, defaulting / to index.html. Every request path is resolved through DirectoryTraversalProtector before the file is touched:
Path root = staticRoot.normalize();
Path requestedFile = root.resolve(path).normalize();
if (!requestedFile.startsWith(root)) {
throw new SecurityException("Access denied");
}A request like GET /../../etc/passwd normalizes outside the static root and is rejected with a 400 rather than served — this is covered by a dedicated test (DirectoryTraversalProtectorTest).
MIME types are resolved by file extension (MimeTypeResolver): .html, .css, .js, .json, .txt, .png, .jpg/.jpeg, .gif, .svg, and .ico are recognized, falling back to application/octet-stream for anything else.
List<Middleware> middlewareList = List.of(
new Logging(),
new Validation()
);Each Middleware returns null to let the request continue, or an HttpResponse to short-circuit the chain. Validation currently enforces:
- Method must be
GETorPOST - Path must be non-empty and start with
/ - HTTP version must be exactly
HTTP/1.1 - A
POSTrequest must have a non-empty body
GlobalExceptionHandler maps exceptions thrown anywhere in the request cycle to HTTP status codes:
| Exception | Status |
|---|---|
IllegalArgumentException (malformed request line, headers, or Content-Length) |
400 Bad Request |
IOException |
500 Internal Server Error |
NullPointerException |
500 Internal Server Error |
| Anything else | 500 Internal Server Error |
Every response — success or failure — is written back through the same ResponseWriter, so the client always gets a well-formed HTTP response.
Requires Java 21 and Maven.
# Clone
git clone https://github.com/Junaid-Ashraf-56/WebServer_Java.git
cd WebServer_Java
# Build and run tests
mvn clean install
# Run
java -jar target/java-web-server.jarThe server listens on port 9090. Try it:
curl http://localhost:9090/hello
curl http://localhost:9090/users
curl http://localhost:9090/docker build -t java-web-server .
docker run --rm -p 9090:9090 java-web-serverThe image is a multi-stage build — a Maven stage compiles and packages the jar, and the runtime stage (eclipse-temurin:21-jre) runs it as a non-root user.
8 tests across 5 test classes, run via JUnit 5 and Maven Surefire:
| Test Class | What it covers |
|---|---|
RouterTest |
Seeded route lookup, unknown route returns null |
ValidationTest |
A valid GET request passes validation cleanly |
MimeTypeResolverTest |
.html, .css, .js resolve to the correct MIME type |
DirectoryTraversalProtectorTest |
A ../ traversal attempt throws SecurityException |
StaticFileHandlerTest |
GET / correctly returns index.html with a 200 |
mvn testWebServer/
├── Dockerfile
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ ├── Main.java
│ │ │ ├── data/
│ │ │ │ └── SeedData.java
│ │ │ ├── error/
│ │ │ │ └── GlobalExceptionHandler.java
│ │ │ ├── handler/
│ │ │ │ ├── RequestHandler.java
│ │ │ │ ├── HelloHandler.java
│ │ │ │ ├── UsersHandler.java
│ │ │ │ └── StaticFileHandler.java
│ │ │ ├── http/
│ │ │ │ ├── request/
│ │ │ │ │ ├── HttpRequest.java
│ │ │ │ │ └── HttpRequestParser.java
│ │ │ │ └── response/
│ │ │ │ ├── HttpResponse.java
│ │ │ │ ├── HttpStatus.java
│ │ │ │ └── ResponseWriter.java
│ │ │ ├── middleware/
│ │ │ │ ├── Middleware.java
│ │ │ │ ├── MiddlewareChain.java
│ │ │ │ ├── Logging.java
│ │ │ │ └── Validation.java
│ │ │ ├── router/
│ │ │ │ └── Router.java
│ │ │ ├── security/
│ │ │ │ └── DirectoryTraversalProtector.java
│ │ │ ├── server/
│ │ │ │ ├── ClientHandler.java
│ │ │ │ └── ServerLifecycle.java
│ │ │ └── util/
│ │ │ └── MimeTypeResolver.java
│ │ └── resources/
│ │ └── static/
│ │ ├── index.html
│ │ ├── about.html
│ │ ├── style.css
│ │ └── app.js
│ └── test/
│ └── java/
│ ├── RouterTest.java
│ ├── ValidationTest.java
│ ├── MimeTypeResolverTest.java
│ ├── DirectoryTraversalProtectorTest.java
│ └── StaticFileHandlerTest.java
This is a learning project, and these are open with intent rather than hidden:
- Only
GETandPOSTare accepted.HEAD,PUT,DELETE, andPATCHare rejected byValidationwith a400rather than a more specific405/501. - No custom rejection policy on the thread pool. A burst of connections beyond 5 running + 15 queued will throw an unhandled
RejectedExecutionExceptionin the accept loop rather than failing gracefully. - The port is hardcoded (
9090inMain.java) — there's no external configuration file yet. - No HTTPS/TLS.

