<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>The File System</title><description>A CS student&apos;s notes on writing code, learning systems, and post-mortems on decisions that seemed smart at the time.</description><link>https://thefilesystem.dev</link><item><title>Spring Boot is magic. So I built an HTTP server in Java to ruin the illusion.</title><link>https://thefilesystem.dev/posts/2026-05-06-http-server-from-scratch</link><guid isPermaLink="true">https://thefilesystem.dev/posts/2026-05-06-http-server-from-scratch</guid><pubDate>Fri, 22 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;Spring Boot makes building a backend almost embarrassingly easy. Slap &lt;code&gt;@RestController&lt;/code&gt; on a class, throw a &lt;code&gt;@GetMapping&lt;/code&gt; on a method, hit &quot;run&quot;, and boom... you&apos;ve got a functioning HTTP server before your coffee&apos;s done brewing. An embedded Tomcat server just... appears. Routes just... work. Annotations just... do things. Magic!&lt;/p&gt;
&lt;p&gt;This is great, until I started asking myself: how does this actually work? What exactly is happening under the hood from the moment I make a &lt;code&gt;GET&lt;/code&gt; request, to the moment I receive a &lt;code&gt;200 OK&lt;/code&gt; back from the server?&lt;/p&gt;
&lt;p&gt;So, I did what curiosity demanded. I decided that I&apos;d take a shot at building my own HTTP server from scratch in Java — just sockets, threads, and bytes.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Goal:&lt;/strong&gt; Develop a minimalistic system that provided enough functionality to allow me to expose some of the abstractions that Spring Boot was hiding from me.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Follow along — by the end, I&apos;ll have pulled back the curtain on some of that sweet, sweet Spring Boot &quot;magic&quot;.&lt;/p&gt;
&lt;p&gt;:::note
The code I reference in this post is available in my &lt;a href=&quot;https://github.com/colinven/http-server-from-scratch-java&quot;&gt;GitHub repo&lt;/a&gt;. I encourage you to dig around, fork the repo, and tinker with it yourself.
:::&lt;/p&gt;
&lt;h2&gt;What We&apos;re Building&lt;/h2&gt;
&lt;p&gt;Let&apos;s not waste any time! So what&apos;s really happening during the lifecycle of an HTTP request?&lt;/p&gt;
&lt;p&gt;At a high level, we&apos;re going to establish a client -&amp;gt; server connection, read incoming bytes off of a socket, parse them into a request object, find the right method to handle it, build a response object, and write bytes back. You can visualize this process like so:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Establish a connection
       ↓
Read bytes off of a socket
       ↓
Parse into a request object
       ↓
Route request to a handler
       ↓
Build a response object
       ↓
Write back as bytes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s the whole job. Pretty simple, right? Spring Boot does this for us right out of the box, but today we&apos;re going to do it ourselves.&lt;/p&gt;
&lt;h2&gt;Step 1: Accepting Connections&lt;/h2&gt;
&lt;p&gt;Our first job is to establish a connection between our client and our server. Java conveniently provides us with &lt;code&gt;java.net.ServerSocket&lt;/code&gt; which we&apos;ll use here.&lt;/p&gt;
&lt;p&gt;You can think of a &lt;code&gt;ServerSocket&lt;/code&gt; as a telephone operator whose job is to sit and wait for the phone to ring. Once a call comes in, the operator hands the caller off to a new private line (a &lt;code&gt;Socket&lt;/code&gt;) to handle that specific conversation. The operator then returns to waiting for the next call.&lt;/p&gt;
&lt;p&gt;All we need to do is create a &lt;code&gt;ServerSocket&lt;/code&gt; bound to a port. We then call its &lt;code&gt;accept()&lt;/code&gt; method, which blocks the calling thread until a client connects. When one does, you get back a new &lt;code&gt;Socket&lt;/code&gt; object  representing that connection. Wrap that whole thing in a loop and voilà, you&apos;ve got a server that can accept connections forever:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void start() throws IOException {
    final int PORT = 8080;
    try (ServerSocket serverSocket = new ServerSocket(PORT)) {
        while (true) {
            Socket client = serverSocket.accept(); // blocks until a client connects
            // ...handle the connection
        }
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s the whole foundation. Every HTTP server on the planet, including the embedded Tomcat that Spring Boot spins up for you, has some version of this loop running underneath. The rest of the work is what happens after &lt;code&gt;accept()&lt;/code&gt; returns.&lt;/p&gt;
&lt;p&gt;Once we&apos;ve established a client connection, and received a &lt;code&gt;Socket&lt;/code&gt; object back, we now need the ability to read and write messages over this connection. HTTP data is transferred as a byte stream (&lt;code&gt;InputStream&lt;/code&gt; and &lt;code&gt;OutputStream&lt;/code&gt;). We can access these streams by calling &lt;code&gt;Socket.getInputStream()&lt;/code&gt; and &lt;code&gt;Socket.getOutputStream()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;But before we worry about HTTP specifically, let&apos;s prove we can successfully move bytes through this socket. For this example, we&apos;ll create a simple &quot;echo server&quot; which will read whatever the client sends us through &lt;code&gt;InputStream&lt;/code&gt;, and send it right back through &lt;code&gt;OutputStream&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That&apos;ll look something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void start() throws IOException {
    final int PORT = 8080;
    try (ServerSocket serverSocket = new ServerSocket(PORT)) {
        while (true) {
            Socket client = serverSocket.accept();
            // client connection established...
            InputStream inputStream = client.getInputStream();
            OutputStream outputStream = client.getOutputStream();

            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            PrintWriter writer = new PrintWriter(outputStream, true); //auto flush
            
            String inputLine;
            // read a line from the buffer, write it back
            while ((inputLine = reader.readLine()) != null) {
                writer.println(inputLine);
            }
            client.close(); // close the connection when buffer is empty
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now let&apos;s test it out. In our terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ echo &quot;Hello, world!&quot; | nc localhost 8080
# Hello, world!
^C
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Success! &lt;code&gt;&quot;Hello, world!&quot;&lt;/code&gt; in -&amp;gt; &lt;code&gt;&quot;Hello, world!&quot;&lt;/code&gt; out.&lt;/p&gt;
&lt;p&gt;Now, I know what you&apos;re thinking. This isn&apos;t quite an HTTP server yet. It doesn&apos;t speak the protocol. At this point, it&apos;s just a glorified byte-pipe. Bytes come in via &lt;code&gt;getInputStream()&lt;/code&gt;, bytes go out via &lt;code&gt;getOutputStream()&lt;/code&gt;. Simply put, HTTP is just a universally agreed-upon format for what those bytes mean. We&apos;ll teach it HTTP in Step 3.&lt;/p&gt;
&lt;p&gt;Now, a Spring Boot controller never makes you touch a stream directly. You write something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@PostMapping(&quot;/echo&quot;)
public String echo(@RequestBody String body) {
    return body;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You get a &lt;code&gt;String&lt;/code&gt; parameter handed to you, and your return value is sent back. But underneath, Tomcat is calling &lt;code&gt;request.getInputStream()&lt;/code&gt; and reading bytes similarly to how we just did. Spring converts those bytes into a &lt;code&gt;String&lt;/code&gt; (using an &lt;code&gt;HttpMessageConverter&lt;/code&gt;), passes it to your method, converts your return value back to bytes, and writes them to &lt;code&gt;response.getOutputStream()&lt;/code&gt;. The streams are still there. They&apos;re just buried under a few layers of &quot;convenience&quot;.&lt;/p&gt;
&lt;p&gt;Currently, our server can establish a connection to a client and read/ write bytes through a &lt;code&gt;Socket&lt;/code&gt;. This is cool, but what happens when our server gets two requests at the same time?&lt;/p&gt;
&lt;h2&gt;Step 2: Handling Many Connections at Once&lt;/h2&gt;
&lt;p&gt;With our current setup, we&apos;d have to wait for each request to finish before handling a new one. That doesn&apos;t scale. Ideally, we want our server to be able to handle lots of requests simultaneously. That&apos;s where we introduce &lt;strong&gt;concurrency&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Conceptually, we need a way for our server to hand off incoming connections to other threads. This way, as requests come in, our main &lt;code&gt;ServerSocket&lt;/code&gt; thread can delegate the work and immediately go back to listening for the next connection.&lt;/p&gt;
&lt;p&gt;There are a couple of ways we can do this in Java (virtual threads, NIO selectors, reactive frameworks). We&apos;ll use a &lt;code&gt;fixed thread pool&lt;/code&gt; with an arbitrary number of threads for this example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
ExecutorService pool = Executors.newFixedThreadPool(50);

public void start() {
    try (ServerSocket serverSocket = new ServerSocket(PORT)) {
        while (true) {
            Socket client = serverSocket.accept();
            pool.submit(() -&amp;gt; handle(client)); // hand off, keep accepting
        }
    } 
}
public void handle(Socket client) {
    // ...read/write bytes, close socket (from previous section)    
}   

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The accept loop is now non-blocking with respect to request handling. &lt;code&gt;accept()&lt;/code&gt; returns a &lt;code&gt;Socket&lt;/code&gt;, we hand it to the pool, and immediately loop back to wait for another connection.&lt;/p&gt;
&lt;p&gt;To prove that the thread pool is actually doing what we think it is, we can fire 50 concurrent connections at the server and clock the time it took to handle those connections.&lt;/p&gt;
&lt;p&gt;For this test, I temporarily added &lt;code&gt;Thread.sleep(100)&lt;/code&gt; in &lt;code&gt;handle()&lt;/code&gt; so each request has a measurable clock time.&lt;/p&gt;
&lt;p&gt;In our terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ time seq 1 50 | xargs -n1 -P50 -I{} sh -c &apos;echo &quot;hello {}&quot; | nc -q1 localhost 8080&apos;

# Sequential (no thread pool): ~6.1 sec
# Concurrent: ~1.2 sec
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As you can see, the thread pool has significantly improved our performance. The 1.2 seconds isn&apos;t pure server time either. About a second of that is &lt;code&gt;nc&lt;/code&gt;&apos;s per-connection wait timer. The point is the &lt;em&gt;contrast&lt;/em&gt; between sequential and concurrent.&lt;/p&gt;
&lt;p&gt;Our server can now handle many connections at once, but every one of those connections is protocol-agnostic. The next layer is where those bytes start to &lt;em&gt;mean&lt;/em&gt; something. Next, we&apos;ll teach our server to read the bytes coming off the socket as structured HTTP requests.&lt;/p&gt;
&lt;h2&gt;Step 3: Parsing Requests&lt;/h2&gt;
&lt;p&gt;At the end of the day, bytes are just bytes. HTTP is what imposes structure on them. When we create a parsing layer, our job is to turn &lt;code&gt;raw bytes&lt;/code&gt; -&amp;gt; &lt;code&gt;request object&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To accomplish this, we must first understand the anatomy of an HTTP request since a request abides by strict syntactic standards. If our incoming request does not abide by these standards, we must consider it malformed and reject it.&lt;/p&gt;
&lt;p&gt;An HTTP/1.1 request consists of four main parts:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Request-Line&lt;/code&gt;: The first line containing the method, target URI, and HTTP version, separated by spaces, terminated by CRLF. (e.g. &lt;code&gt;GET /users HTTP/1.1\r\n&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Request Headers&lt;/code&gt;: Key-value pairs providing metadata, each on a new line, terminated by CRLF (e.g. &lt;code&gt;Host: example.com\r\n&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Blank Line&lt;/code&gt;: A mandatory empty line (CRLF) to signify the transition from headers -&amp;gt; body.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Body&lt;/code&gt; (optional): The payload of the request.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This comes together to look something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /greeting HTTP/1.1\r\n
Host: example.com\r\n
User-Agent: Mozilla/5.0\r\n
Content-Type: text/plain\r\n
Content-Length: 13\r\n
\r\n
Hello, World!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When we parse a request, our job is to turn this block of text into a structured &lt;code&gt;HttpRequest&lt;/code&gt; object that we can work with.&lt;/p&gt;
&lt;p&gt;To accomplish this, we want to &quot;walk through&quot; the request byte-by-byte, acknowledge what &lt;em&gt;portion&lt;/em&gt; of the request we are currently in, and parse that specific portion accordingly. For this, we&apos;ll build a &lt;code&gt;State Machine&lt;/code&gt; that keeps an internal state variable that tells us what section of the request we are currently in.&lt;/p&gt;
&lt;p&gt;For clarity, these are the data structures that we&apos;ll be constructing from the raw request:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;record HttpRequest(RequestLine requestLine, Map&amp;lt;String, List&amp;lt;String&amp;gt;&amp;gt; headers, byte[] body) {}
record RequestLine(HttpMethod method, String target, String version) {}
enum HttpMethod { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here&apos;s the skeleton of our state machine, with the internals stubbed out to keep it concise for this article (I encourage you to take a look at the full code in the &lt;a href=&quot;https://github.com/colinven/http-server-from-scratch-java/blob/main/src/main/java/com/myhttpserver/app/parser/RequestParser.java&quot;&gt;repo&lt;/a&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;private enum ParserState {
    REQUEST_LINE,
    HEADER_NAME,
    HEADER_VALUE,
    BODY,
    COMPLETE,
    ERROR
}

ParserState state = ParserState.REQUEST_LINE;
StringBuilder buffer = new StringBuilder();

int prev = -1;
int curr;

while ((curr = in.read()) != -1) {
    switch (state) {
        case REQUEST_LINE -&amp;gt; {
            // read bytes into buffer until CRLF, then parse:
            // &quot;GET /users HTTP/1.1&quot; -&amp;gt; new RequestLine(&quot;GET&quot;, &quot;/users&quot;, &quot;HTTP/1.1&quot;). 
            // change state to HEADER_NAME.
        }
        case HEADER_NAME -&amp;gt; {
            // read until &apos;:&apos; -&amp;gt; capture headerName -&amp;gt; change state to HEADER_VALUE
            // if CRLF (blank line) -&amp;gt; change state to BODY
        }
        case HEADER_VALUE -&amp;gt; {
            // read until CRLF, insert (headerName, headerValue) into map, 
            // change state back to HEADER_NAME (next header)
        }
        case ERROR -&amp;gt; throw new HttpParseException(400, &quot;Malformed request&quot;);
    }

    if (state == ParserState.BODY) {
        // if Content-Length header is present, read exactly that many bytes
        // change state to COMPLETE
    }
    if (state == ParserState.COMPLETE) break;
    prev = curr;
}

return new HttpRequest(requestLine, headers, body.length &amp;gt; 0 ? body : null);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The loop reads one byte at a time. Depending on which state we&apos;re in, that byte either gets accumulated into the current buffer (mid-token) or triggers a state transition (end of token, like &lt;code&gt;\r\n&lt;/code&gt; or &lt;code&gt;:&lt;/code&gt;). Once we transition to &lt;code&gt;BODY&lt;/code&gt;, we leave the byte-by-byte loop and read the body in one shot. We read exactly &lt;code&gt;n&lt;/code&gt; bytes where &lt;code&gt;n = Content-Length&lt;/code&gt;. If there is no &lt;code&gt;Content-Length&lt;/code&gt; header present, we skip this step entirely.&lt;/p&gt;
&lt;p&gt;If we encounter any part of the request which is malformed (missing &lt;code&gt;Host&lt;/code&gt; header, bare &lt;code&gt;LF&lt;/code&gt; without &lt;code&gt;CR&lt;/code&gt;, invalid HTTP version, etc.), we throw a &lt;code&gt;MalformedRequestException&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This parser handles the basics (request line, headers, Content-Length framing, malformed input rejection). Production parsers handle chunked Transfer-Encoding, header folding, percent-decoding, and a dozen other edge cases I didn&apos;t touch. The point isn&apos;t to compete with Tomcat, but rather to make the shape of the work visible so that the next time you write &lt;code&gt;@RequestBody User user&lt;/code&gt; in your Spring app, you&apos;ve got an idea of what&apos;s happening under the hood.&lt;/p&gt;
&lt;p&gt;So now we&apos;ve got a parsed &lt;code&gt;HttpRequest&lt;/code&gt; which contains a method, a target, headers, and a body. Now what? We need to figure out what to do with it. This is where &lt;code&gt;routing&lt;/code&gt; comes in. Next, we&apos;ll build the mechanism that matches each endpoint with the code that handles it.&lt;/p&gt;
&lt;h2&gt;Step 4: Routing to a Handler&lt;/h2&gt;
&lt;p&gt;Let&apos;s clarify exactly what our routing mechanism needs to do. Two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Keep an internal &lt;code&gt;registry&lt;/code&gt; that says &quot;when a &lt;code&gt;GET /greeting&lt;/code&gt; request comes in, run &lt;em&gt;this&lt;/em&gt; code&quot;.&lt;/li&gt;
&lt;li&gt;Given an incoming request, we need to search through that registry to &lt;em&gt;find&lt;/em&gt; the right code to run.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Our end goal is to create an intuitive interface that looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Router router = new Router();
// register our routes
router.get(&quot;/greeting&quot;, (req) -&amp;gt; { /* our desired action/method */ });
router.post(&quot;/echo&quot;, (req) -&amp;gt; { /* our desired action/method */ });

RouteHandler handler = router.getHandler(request); // find the correct route
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first thing we need to define is our &lt;code&gt;RouteHandler&lt;/code&gt;. In our case, a handler is simply any method that accepts an &lt;code&gt;HttpRequest&lt;/code&gt; object as a parameter, and returns an &lt;code&gt;HttpResponse&lt;/code&gt; (we&apos;ll cover response shapes in step 5). This handler will represent the logic that we wish to run for a particular route.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;@FunctionalInterface
public interface RouteHandler {
    HttpResponse handle(HttpRequest request) throws Exception;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;@FunctionalInterface&lt;/code&gt; annotation is our contract. A &lt;code&gt;RouteHandler&lt;/code&gt; is &lt;em&gt;anything&lt;/em&gt; shaped like &lt;code&gt;HttpRequest -&amp;gt; HttpResponse&lt;/code&gt;. The router doesn&apos;t know if the handler queries a database, returns a hardcoded string, or launches missiles. It only knows about the shape.&lt;/p&gt;
&lt;p&gt;Next, we need a way to uniquely identify a route by its &lt;code&gt;method&lt;/code&gt; and its &lt;code&gt;path&lt;/code&gt;. &lt;code&gt;GET /users&lt;/code&gt; and &lt;code&gt;POST /users&lt;/code&gt; are two different routes. Same path, different code. That&apos;s where a &lt;code&gt;RouteKey&lt;/code&gt; comes in:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public record RouteKey(HttpMethod method, String path) {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, we have a way to map a specific &lt;code&gt;method&lt;/code&gt; to a &lt;code&gt;path&lt;/code&gt; to create a &lt;code&gt;key&lt;/code&gt;. Next, we need a way to map that &lt;code&gt;key&lt;/code&gt; to a &lt;code&gt;handler&lt;/code&gt;. This is where our &lt;code&gt;Router&lt;/code&gt; class begins to come together.&lt;/p&gt;
&lt;p&gt;Inside of this class, we will keep an internal &lt;code&gt;registry&lt;/code&gt; for all of the available &lt;code&gt;routes&lt;/code&gt; in our application. We&apos;ll then create a few methods which will allow us to add specific routes to that registry:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public class Router {

    private final Map&amp;lt;RouteKey, RouteHandler&amp;gt; registry = new HashMap&amp;lt;&amp;gt;();

    public void get(String path, RouteHandler handler) {
        registry.put(new RouteKey(HttpMethod.GET, path), handler);
    }
    public void post(String path, RouteHandler handler) {
        registry.put(new RouteKey(HttpMethod.POST, path), handler);
    }
    // ... put(), patch(), delete() -&amp;gt; same shape
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;Router&lt;/code&gt; is literally just a &lt;code&gt;HashMap&lt;/code&gt; with a friendlier API. That&apos;s it. &lt;code&gt;get()&lt;/code&gt; and &lt;code&gt;post()&lt;/code&gt; just wrap &lt;code&gt;put()&lt;/code&gt; with the method baked in. Now that we have a way to store routes, the only question left to answer is how we look up those routes.&lt;/p&gt;
&lt;p&gt;To solve this, we&apos;ll create a method which decomposes the incoming &lt;code&gt;HttpRequest&lt;/code&gt;, extracts the &lt;code&gt;method&lt;/code&gt; and &lt;code&gt;path&lt;/code&gt; from the &lt;code&gt;RequestLine&lt;/code&gt;, constructs a &lt;code&gt;RouteKey&lt;/code&gt; for that particular request, looks through the &lt;code&gt;registry&lt;/code&gt; for a matching key, and returns the correct &lt;code&gt;RouteHandler&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public RouteHandler getHandler(HttpRequest request) {
        HttpMethod method = request.requestLine().method();
        String path = request.requestLine().target();

        return registry.get(new RouteKey(method, path));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice that &lt;code&gt;getHandler()&lt;/code&gt; returns a &lt;code&gt;RouteHandler&lt;/code&gt;, not an &lt;code&gt;HttpResponse&lt;/code&gt;. The router&apos;s job ends at &quot;here&apos;s the method you should call.&quot; Actually calling that method happens back in our &lt;code&gt;handle()&lt;/code&gt; method.&lt;/p&gt;
&lt;p&gt;Down the road, if we need to add any middleware (auth, logging, rate limiting, etc.), we can slot it in between lookup and invocation. Fuse those two steps together and you&apos;d have to cram that logic into the router itself, which has nothing to do with routing.&lt;/p&gt;
&lt;p&gt;Spring&apos;s &lt;code&gt;HandlerMapping&lt;/code&gt; makes the same call for the exact same reason. It doesn&apos;t invoke the handler directly. Instead, it returns a &lt;code&gt;HandlerExecutionChain&lt;/code&gt; which contains the handler itself, along with any interceptors that must run before or after it. Auth, logging, the same things we just talked about. Same seam, same reason.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void handle(Socket client) {
    try (
            BufferedInputStream input = new BufferedInputStream(client.getInputStream());
            BufferedOutputStream output = new BufferedOutputStream(client.getOutputStream());
        ){

            HttpRequest request = parser.parseRequest(input);
            RouteHandler handler = router.getHandler(request);
            HttpResponse response = handler.handle(request);
            // ...write to client (next section)
    } 
} 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::note
A small change: I switched from &lt;code&gt;BufferedReader&lt;/code&gt;/&lt;code&gt;PrintWriter&lt;/code&gt;, to &lt;code&gt;BufferedInputStream&lt;/code&gt;/&lt;code&gt;BufferedOutputStream&lt;/code&gt;. The reader/writer pair assumes character data, but HTTP bodies may be binary (images, gzipped JSON). Reading at the byte level is more honest.
:::&lt;/p&gt;
&lt;p&gt;While we&apos;re at it, we&apos;ll also create a method for configuring our routes. You can create this method in its own config class, but for clarity I&apos;ll just create a &lt;code&gt;configureRoutes()&lt;/code&gt; method within our &lt;code&gt;HttpServer&lt;/code&gt; class:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public class HttpServer {
    private final Router router = new Router();
    private final RequestParser parser = new RequestParser();
    private final int PORT = 8080;
    
    public void start() throws IOException {
        configureRoutes();
        
        try (ServerSocket serverSocket = new ServerSocket(PORT)) {
            while (true) {
                Socket client = serverSocket.accept();
                pool.submit(() -&amp;gt; handle(client));
            }
        }
    }
    
    private void configureRoutes() {
        router.get(&quot;/greeting&quot;, req -&amp;gt; HttpResponse.ok(&quot;hello, world&quot;));
        router.post(&quot;/echo&quot;, req -&amp;gt; HttpResponse.ok(req.body()));
    }
    
    private void handle(Socket client) {
        // ...previous snippet
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Step 5: Building &amp;amp; Writing the Response&lt;/h2&gt;
&lt;p&gt;We&apos;ve spent four sections turning bytes into something meaningful. Now we run the whole thing in reverse. The response layer is &lt;code&gt;HttpResponse&lt;/code&gt; object → bytes on the wire.&lt;/p&gt;
&lt;p&gt;A response has the same shape as a request: a status line, headers, a blank line, and a body.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP/1.1 200 OK\r\n
Content-Type: text/plain\r\n
Content-Length: 13\r\n
\r\n
Hello, World!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The data model mirrors the request side:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public class HttpResponse {
    private final int statusCode;
    private final String reasonPhrase;
    private final Map&amp;lt;String, String&amp;gt; headers;
    private final byte[] body;

    // constructor, getters omitted...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Building one of these by hand every time would be tedious and error-prone. Every handler would have to remember to set &lt;code&gt;Content-Length&lt;/code&gt;, pick a sensible &lt;code&gt;Content-Type&lt;/code&gt;, and get the status code right. Three opportunities to screw it up.&lt;/p&gt;
&lt;p&gt;We&apos;ll centralize that work with factory methods:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public static HttpResponse okText(String body) {
    byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
    Map&amp;lt;String, String&amp;gt; headers = new HashMap&amp;lt;&amp;gt;();
    headers.put(&quot;Content-Type&quot;, &quot;text/plain&quot;);
    headers.put(&quot;Content-Length&quot;, String.valueOf(bodyBytes.length));
    return new HttpResponse(200, headers, bodyBytes);
}

// notFound(), badRequest()... same shape -&amp;gt; different status code
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Static factory methods provide us with a way to enforce correctness. One place to set Content-Length, one place to pick the default Content-Type, one place to fix when you get it wrong.&lt;/p&gt;
&lt;p&gt;Now we need to write the response back to the client. Order matters: status line, headers, blank line, body. Get this wrong and the client can&apos;t parse what you sent.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void write(BufferedOutputStream out) throws IOException {
    StringBuilder sb = new StringBuilder();
    sb.append(&quot;HTTP/1.1 &quot;).append(statusCode).append(&quot; &quot;).append(reasonPhrase).append(&quot;\r\n&quot;);
    headers.forEach((k, v) -&amp;gt; sb.append(k).append(&quot;: &quot;).append(v).append(&quot;\r\n&quot;));
    sb.append(&quot;\r\n&quot;);
    out.write(sb.toString().getBytes(StandardCharsets.UTF_8));
    if (body != null &amp;amp;&amp;amp; body.length &amp;gt; 0) out.write(body);
    out.flush();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Content-Length&lt;/code&gt; is very important here. It tells the client where the response ends. Without it (or chunked encoding, which we didn&apos;t build), the client doesn&apos;t know if it&apos;s done reading, so it just... waits. For eternity.&lt;/p&gt;
&lt;p&gt;Now the full lifecycle fits in one method:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void handle(Socket client) {
    try (
        BufferedInputStream input = new BufferedInputStream(client.getInputStream());
        BufferedOutputStream output = new BufferedOutputStream(client.getOutputStream());
    ) {
        HttpRequest request = parser.parseRequest(input);
        RouteHandler handler = router.getHandler(request);
        HttpResponse response = handler == null
            ? HttpResponse.notFound()
            : handler.handle(request);
        response.write(output);
    } catch (Exception e) {
        // ... error handling
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s the entire request lifecycle in ten lines. Accept, parse, route, invoke, write. No matter how much framework you pile on top, the loop at the bottom looks like this. Tomcat&apos;s version is more defensive, more configurable, and significantly more battle-tested, but you get the point.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One last look at Spring.&lt;/strong&gt; In your controller method, &lt;code&gt;return user;&lt;/code&gt; is doing &lt;em&gt;a lot&lt;/em&gt;. Under the hood, Spring wraps your object in a &lt;code&gt;ResponseEntity&lt;/code&gt;, picks an &lt;code&gt;HttpMessageConverter&lt;/code&gt; based on the &lt;code&gt;Accept&lt;/code&gt; header (&lt;code&gt;MappingJackson2HttpMessageConverter&lt;/code&gt; for JSON by default), serializes the object to bytes, sets &lt;code&gt;Content-Length&lt;/code&gt; and &lt;code&gt;Content-Type&lt;/code&gt;, and writes it all to the output stream. The factory methods we just wrote do the same job, but for strings. The difference is that Spring&apos;s converters know how to turn &lt;em&gt;any&lt;/em&gt; object into bytes, and will pick the correct converter based on what the client asked for.&lt;/p&gt;
&lt;h2&gt;What I Didn&apos;t Build&lt;/h2&gt;
&lt;p&gt;The server we just built handles the happy path. Real HTTP servers handle a hundred ugly edge cases on top of that. Here&apos;s a quick map of what I deliberately skipped, so you know where the gaps are.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Chunked Transfer Encoding.&lt;/strong&gt; Our parser requires a &lt;code&gt;Content-Length&lt;/code&gt; header to read the body. That works just fine in instances where the sender knows the body size upfront, but breaks for streaming responses where the length isn&apos;t known until the last byte. HTTP/1.1&apos;s answer is chunked encoding. With chunked encoding, the body comes in pieces, each prefixed with its own length, terminated by a zero-length chunk. Not too hard conceptually, but it&apos;s another state in the parser and another branch in the response writer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Path variables and pattern matching.&lt;/strong&gt; This is probably the biggest &quot;wait, can you actually use this?&quot; gap. Our router does exact-string matching: &lt;code&gt;/users&lt;/code&gt; works, &lt;code&gt;/users/1234&lt;/code&gt; would need to be registered separately. Real routers parse path templates such as &lt;code&gt;/users/{id}&lt;/code&gt; with either a regex or a trie, capture the matched variables, and stash them on the request so the handler can read them. This is what makes REST APIs feel like REST APIs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Virtual Threads.&lt;/strong&gt; I used a fixed thread pool in Step 2. This model will scale to a few thousand concurrent connections, but OS thread overhead becomes the bottleneck. For ~20 years, Java&apos;s answer to &quot;more concurrency&quot; was async and reactive code (Reactor, WebFlux). The tradeoff was increased complexity for more connections-per-thread. Java 21&apos;s virtual threads eliminates that tradeoff. Tomcat 10.1+ supports them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hardening.&lt;/strong&gt; Currently, our server will happily fall over to anyone who looks at it funny. There are a couple of examples worth naming. First, request size limits: a malicious client can send a 10GB request line with no newlines and our parser will buffer it all into memory before exploding. Real servers cap header sizes and reject anything over the limit. Second, slowloris: a client opens a connection, sends one byte every thirty seconds, and never finishes the request. The thread pool fills up with idle connections and the server stops accepting new ones. Real servers enforce read timeouts. There&apos;s more. TLS attacks, header injection, request smuggling, you get the point. Production HTTP servers need to be bulletproof.&lt;/p&gt;
&lt;h2&gt;Closing Thoughts&lt;/h2&gt;
&lt;p&gt;Accept, parse, route, invoke, write. Five verbs, five sections, and the whole request lifecycle fits in one &lt;code&gt;handle()&lt;/code&gt; method. The black box has fewer sides now.&lt;/p&gt;
&lt;p&gt;At the end of the day, when you use a framework, you&apos;re inheriting decisions that someone else made, and code that you never had to write. That&apos;s leverage. Most of the time, it&apos;s exactly what you want. The &quot;magic&quot; that everyone talks about? It&apos;s just layers, and most days you don&apos;t need to look under them.&lt;/p&gt;
&lt;p&gt;The value of building something from scratch isn&apos;t that you&apos;d ever ship it. It&apos;s that when something breaks, you know where to look to fix it. That&apos;s the real leverage.&lt;/p&gt;
&lt;p&gt;If you&apos;re feeling ambitious, I encourage you to fork the &lt;a href=&quot;https://github.com/colinven/http-server-from-scratch-java&quot;&gt;repo&lt;/a&gt;, pick one thing from the &quot;What I Didn&apos;t Build&quot; section, and implement it yourself.&lt;/p&gt;
</content:encoded><author>Colin Venancio</author></item><item><title>Accelerant or Substitute: Learning to Code in the Age of AI.</title><link>https://thefilesystem.dev/posts/2026-06-07-learning-with-ai</link><guid isPermaLink="true">https://thefilesystem.dev/posts/2026-06-07-learning-with-ai</guid><pubDate>Tue, 09 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The Trap&lt;/h2&gt;
&lt;p&gt;You&apos;re deep in a project and staring at a feature that you have &lt;em&gt;no idea&lt;/em&gt; how to build. Claude and ChatGPT are just a click away. You could take the time to actually understand the topic — the fundamentals, the nuances, why it works the way it does. Or you could take the path of least resistance: &quot;Hey Claude, help me implement &lt;code&gt;X&lt;/code&gt; feature in this project...&quot;. Next thing you know, AI spits out a couple hundred lines of code.&lt;/p&gt;
&lt;p&gt;&quot;Seems legit!&quot;, you tell yourself. Copy, paste, &quot;It works,&quot; move on. Felt productive... but did you &lt;em&gt;really&lt;/em&gt; learn anything? If I asked you to reason about that code, could you do it?&lt;/p&gt;
&lt;p&gt;If the answer is no, you&apos;re not lazy and you&apos;re not stupid. You&apos;re just using the tool the way it was designed to be used. Something has fundamentally changed about how we access knowledge, and almost nobody is talking about what it&apos;s silently doing to us.&lt;/p&gt;
&lt;h2&gt;The New Interface to Knowledge&lt;/h2&gt;
&lt;p&gt;Just a few years ago, finding the solution to a problem meant analyzing multiple sources, reading through docs, digging through forums, and genuinely wrestling with the concept. AI has stripped away all of that friction. Now, we have instant conversational access to all the information we want.&lt;/p&gt;
&lt;p&gt;The deceiving part is that this smooth, frictionless consumption feels like learning, but it&apos;s really just &lt;strong&gt;exposure&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Exposure != Learning&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The default output of this new frictionless interface is breadth without depth: we feel like we know a little bit of everything, but can&apos;t actually articulate any of it. We can touch a hundred topics in an afternoon and retain none of them — &lt;strong&gt;because retention was the side effect of the friction that just got deleted.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Real learning happens when we sit with a problem past the point of comfort. On the other side of that annoyance and frustration lies true understanding. But now, nothing about the information-gathering process forces that on us. Instead of scouring the depths of the internet, the answer is one prompt away. The mechanism that developed depth was removed from the equation.&lt;/p&gt;
&lt;p&gt;On the flip side: that same friction removal is also the best thing that has ever happened to learning &lt;strong&gt;(if we choose to use it that way).&lt;/strong&gt; The tool raised the ceiling and removed the floor at the same time. We can research concepts deeper and faster than any generation of programmers before us. We can read source code with an expert explaining every line as we go. We can correct a confused mental model within seconds instead of struggling with it for a week. Before AI, that kind of depth took time and effort. Now, it&apos;s at our fingertips.&lt;/p&gt;
&lt;p&gt;But that&apos;s the trap. The floor is gone. Nothing forces us to do the deep work anymore. Depth has become optional.&lt;/p&gt;
&lt;h2&gt;Accelerant vs. Substitute&lt;/h2&gt;
&lt;p&gt;The core distinction between AI usage that &lt;em&gt;supports&lt;/em&gt; learning, and AI usage that &lt;em&gt;inhibits&lt;/em&gt; learning comes down to &lt;em&gt;when&lt;/em&gt; in the loop we choose to reach for the tool. Let me explain:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Scenario A:&lt;/strong&gt; You hit that new feature. You don&apos;t understand it. Your first move: &quot;Hey Claude, help me implement this feature...&quot; Code appears, it works, you move on. The tool didn&apos;t reinforce your thinking... there was no thinking to reinforce! You completely skipped the step that would have built understanding.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; You ship the feature. You still have no clue how it works. The next time you encounter the same topic, you&apos;ll be just as lost. You learned nothing.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Scenario B:&lt;/strong&gt; Same feature, same confusion. But this time, you sit with the problem. You read what you can — and based on that information, you form a hypothesis: &quot;I &lt;em&gt;think&lt;/em&gt; it works this way, and &lt;code&gt;X&lt;/code&gt; seems like the hard part.&quot; You take a swing, hit a wall, and pin down exactly where you&apos;re stuck.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Then&lt;/em&gt; you reach for the AI, but you&apos;re not asking it to build the thing for you, you&apos;re asking it to resolve the gaps in your own mental model: &quot;Hey Claude, I&apos;m trying to implement this feature... here&apos;s what I understand about it... here&apos;s what I don&apos;t understand about it... what am I missing here?&quot;&lt;/p&gt;
&lt;p&gt;The AI picks apart your understanding, reinforces what&apos;s right, corrects what&apos;s wrong, and clarifies the fuzzy parts. The answer &lt;em&gt;sticks&lt;/em&gt; because it lands on a mental model that you already constructed yourself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; You ship the feature. But this time, you know how it works and &lt;em&gt;why&lt;/em&gt; it works. The next time you encounter the same topic, instead of relearning it, you&apos;ll recognize it. You&apos;ll &lt;em&gt;remember being wrong about it&lt;/em&gt; — and that memory holds a lot more cognitive weight than simply being told the right answer.&lt;/p&gt;
&lt;p&gt;Notice what&apos;s identical in those two scenarios? In both, we utilized AI for help, but the timing of &lt;em&gt;when&lt;/em&gt; we called for help changed.&lt;/p&gt;
&lt;p&gt;When we&apos;re using AI as a tool for learning, we &lt;em&gt;must&lt;/em&gt; ask ourselves &quot;Am I using AI to reinforce my thinking, or to outsource it?&quot; There&apos;s a fine line between the two: one uses the tool as an &lt;strong&gt;accelerant&lt;/strong&gt;, the other uses it as a &lt;strong&gt;substitute.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The Rules I Actually Use&lt;/h2&gt;
&lt;p&gt;So how do you actually use AI as an accelerant for learning? I&apos;m going to share a few &apos;rules&apos; that I always follow that keep me in the right mindset:&lt;/p&gt;
&lt;h3&gt;Earn the Question.&lt;/h3&gt;
&lt;p&gt;A good rule of thumb: the questions you ask AI should be specific and concrete. This is because the action of forming a &lt;em&gt;good&lt;/em&gt; question requires you to have previously internalized the problem well enough to find the exact edge of where your understanding breaks. &lt;strong&gt;The vagueness of your question is the measurement of your ignorance.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Here&apos;s the test: can you ask a &lt;em&gt;specific&lt;/em&gt; question about the topic? Not &quot;how does this work&quot;, but &quot;I think this works like &lt;code&gt;X&lt;/code&gt;, so why does &lt;code&gt;Y&lt;/code&gt; happen?&quot; If you can&apos;t get more specific than &quot;I don&apos;t get it,&quot; you haven&apos;t truly wrapped your head around the problem yet.&lt;/p&gt;
&lt;h3&gt;Make the AI teach, not tell.&lt;/h3&gt;
&lt;p&gt;By default, AI is a vending machine. Put a question in, an answer drops out. Ask AI to teach you something and it&apos;ll default to just... telling you. Now don&apos;t get me wrong, this is great for getting unblocked, but it&apos;s a &lt;em&gt;terrible&lt;/em&gt; way to learn. That&apos;s like your professor giving you the answer key to every exam — convenient, not effective.&lt;/p&gt;
&lt;p&gt;The fix is to provide the AI with custom instructions that define &lt;em&gt;how&lt;/em&gt; it should respond to your requests. By doing this, we can turn our high-tech vending machine into a tutor that actually makes us think.&lt;/p&gt;
&lt;p&gt;Instead of dumping the answer on us, we program it to push back: ask us what we already think, make us reason toward the solution, point at &lt;em&gt;where&lt;/em&gt; our logic breaks instead of just fixing it, hand us a missing fact when we genuinely can&apos;t derive it ourselves, and only hand over the direct answer when we explicitly ask for it.&lt;/p&gt;
&lt;p&gt;Here&apos;s the prompt I actually use. Steal it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;When I&apos;m trying to learn or understand a concept, act as a Socratic tutor rather than 
answering immediately:
- First ask what I already know or think, and make me reason toward the answer myself. 
  But calibrate — if I clearly already grasp the basics, skip ahead; don&apos;t make me 
  re-derive things I know.
- When my reasoning is wrong, tell me it&apos;s wrong and point to where it broke — don&apos;t 
  just correct it for me.
- Use follow-up questions to push me to the next step instead of completing it for me.
- You can give me a specific fact, term, or piece of syntax I couldn&apos;t reasonably derive 
  on my own, but only the minimum needed to unblock my next step, and never the conclusion 
  I&apos;m working toward. Hand me the puzzle piece, not where it goes.
- Escalate when I&apos;m stuck: if I&apos;ve taken a couple of real swings at the same point without 
  progress, give me a hint that points the way — not the answer. If I&apos;m still stuck after 
  that, or I say &quot;just tell me,&quot; give me the direct answer.

This applies when I&apos;m learning. When I&apos;m clearly trying to unblock a task — debugging, 
looking up syntax, shipping something — just answer directly; don&apos;t make me reason through it.

When I ask you to review something I wrote to test my understanding, be direct and specific 
about where I&apos;m vague, wrong, or hand-waving — name it rather than smoothing over it.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Copy and paste that prompt into your AI&apos;s custom instructions. The exact path depends on the provider you use. As of the time of writing this article:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Claude: Settings -&amp;gt; General -&amp;gt; Instructions for Claude&lt;/li&gt;
&lt;li&gt;ChatGPT: Settings -&amp;gt; Personalization -&amp;gt; Custom Instructions&lt;/li&gt;
&lt;li&gt;Gemini: Settings &amp;amp; Help -&amp;gt; Personal Context -&amp;gt; Your Instructions for Gemini&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Never accept code you couldn&apos;t rewrite tomorrow.&lt;/h3&gt;
&lt;p&gt;First off, I&apos;d like to say that I&apos;m not against coding agents. Agentic coding could very well become the default in a few years. Even when learning, there are times where letting AI generate the code is the right call — sometimes code is the clearest way to express an idea.&lt;/p&gt;
&lt;p&gt;Generating code is fine... &lt;em&gt;accepting code you don&apos;t understand&lt;/em&gt; is the problem — and it&apos;s breadth-without-depth in its purest form. The trap is how easy it is: the code works, you tell yourself you get it, you move on. But &quot;it runs&quot; and &quot;I understand it&quot; are two very different claims.&lt;/p&gt;
&lt;p&gt;Here&apos;s a test to run through &lt;em&gt;before&lt;/em&gt; you click &quot;Approve edits&quot; in your coding agent: &lt;strong&gt;if someone asked me to explain this code, could I articulate how it works, why it&apos;s written the way it is, and point out any associated tradeoffs of the approach?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If the answer&apos;s no, you didn&apos;t learn anything — you just moved a problem you couldn&apos;t solve into a codebase that you can no longer fully explain. That&apos;s a skill issue.&lt;/p&gt;
&lt;p&gt;The flip side of this is having the judgment to know &lt;em&gt;when&lt;/em&gt; to reach for generated code. Boilerplate that you&apos;ve written a hundred times? Let the AI cook. A new design pattern or API that you&apos;ve never used? That&apos;s probably a good point to slow down and truly understand every line, because that&apos;s where the real learning occurs.&lt;/p&gt;
&lt;h3&gt;Write to find the holes.&lt;/h3&gt;
&lt;p&gt;If you really want to expose your knowledge gaps on any given topic, write about it. Open a fresh txt file and write out your understanding of it as if you were teaching it to someone who knows &lt;em&gt;nothing&lt;/em&gt; about it. This should be straight off the dome — no peeking at Google.&lt;/p&gt;
&lt;p&gt;This works extraordinarily well because writing forces ambiguity to the surface. You can&apos;t write a clean explanation of something you don&apos;t actually understand. The fuzzy parts show up as the sentences you can&apos;t finish.&lt;/p&gt;
&lt;p&gt;Once you have your brain-dump of an explanation written out, hand it over to AI: &quot;...here&apos;s my understanding of &lt;code&gt;X&lt;/code&gt;. Pick it apart: where am I wrong, where am I being vague, and what am I missing?&quot; By taking this approach, the AI is not generating your understanding, it&apos;s stress-testing the one you&apos;ve already built.&lt;/p&gt;
&lt;p&gt;Full circle: those &lt;em&gt;specific, concrete&lt;/em&gt; questions from the first rule? This is where they come from. You write, and the gaps reveal themselves. Each gap is a precise question you&apos;ve earned the right to ask.&lt;/p&gt;
&lt;h2&gt;The Discipline&lt;/h2&gt;
&lt;p&gt;Here&apos;s what it comes down to. The tools aren&apos;t going anywhere, and they&apos;re only going to get better at handing us answers. The way we access information has fundamentally changed — and that has tradeoffs. The increasingly valuable skill is the ability to think deeply and critically — which is the same skill that this new frictionless interface will atrophy without discipline.&lt;/p&gt;
&lt;p&gt;The discipline is easy to state and hard to practice: do the thinking &lt;em&gt;before&lt;/em&gt; we reach for the tool, not instead of it. Struggle first, then ask. Build the mental model, then let AI correct it — not the other way around. The hard part isn&apos;t understanding this principle — it&apos;s that the shortcut is one keystroke away every single time, and nothing forces us to skip it. We have to &lt;em&gt;choose&lt;/em&gt; it, over and over.&lt;/p&gt;
&lt;p&gt;I don&apos;t always get this right. Plenty of times the shortcut&apos;s right there and I take it, and tell myself I&apos;ll understand it later. But the difference between continuous growth and stalling out isn&apos;t talent, it&apos;s the act of choosing the harder path — even when the easy one is one click away. That&apos;s the real work.&lt;/p&gt;
</content:encoded><author>Colin Venancio</author></item></channel></rss>