From TCP to Web

At its core, a web application is just a conversation between two programs over the network. That conversation follows a protocol, and before we get to HTTP and the web, we need to understand the layer below it. The Internet Protocol (IP) is the raw foundation: it ships discrete packets of data across the network, but it ships them unreliably. Packets can arrive out of order, or not at all.

To fix that, we add the Transmission Control Protocol (TCP) on top of IP. TCP adds reliability by letting the sender stamp an auto-incrementing sequence number on each packet so the receiver can reassemble the stream of data in the right order. It also introduces the concept of sockets, which represent a persistent connection between a server and a client.

The First Layer: Creating a Server

Ruby ships with a core socket library that wraps the TCP implementation your operating system already provides. Using it, you can create a TCP server socket that listens for incoming connections on a specified port. For our application, called Mirth, we'll bind to port 1337.

The server creates its socket and then loops, accepting any clients that come looking for a connection. Each accepted client provides an IO object through which data flows. Reading from that IO object gives us the client’s input; writing to it lets us respond. In a minimal example, we can ask the client, "What's your name?", read the reply, and echo back a modified greeting before closing the connection.

Once you’ve written the code, running the Ruby file creates the server socket. To test it, you would open a second terminal and connect using netcat with nc localhost 1337. Any input typed into that session gets read by the server, which then responds and closes the connection.

How to Build a Web App with and without Rails Libraries

From Raw Sockets to HTTP Messaging

With a working TCP server, the next step is speaking the language of the web: HTTP. While TCP is application-agnostic, HTTP is specific to web communication. This means we can implement HTTP directly in Ruby without pulling in any external libraries.

Every HTTP message follows the same structure:

  • A start line
  • Zero or more header fields
  • A blank line
  • An optional message body

The start line differentiates requests from responses. A request uses a request-line, while a response uses a status-line. The message structure and its fields are defined in the RFC documents maintained by the HTTP Working Group.

Parsing Incoming Requests

A request’s start line carries three critical pieces of information. The method token (typically GET or POST) tells the server what action to perform. The target is the path, such as baked-brownies/123 from a URL like www.mirth.com/baked-brownies/123. The version number, most commonly HTTP/1.1, indicates which HTTP version the client speaks.

After accepting a client socket, the server reads the request line. Note that #readline differs from #gets in that it raises an error when no more input exists.

A directed network diagram showing the layers of abstraction between 3 network protocols (IP, TCP, HTTP). The bottom layer is the Network Layer that uses IP. That flows up to the Transport Layer that uses TCP. That then flows to the Application Layer which using HTTP.
Abstraction layers of network protocols

Parsing the request line requires splitting it into its component parts: method_token, target, and version_number. Each controls how the server responds. A simple implementation prints the parsed request details and echoes them back in the response body.

To test this, run the code from the mirth-2.rb gist:

  1. Run ruby mirth-2.rb.
  2. In your browser, navigate to localhost:1337/cakes-and/pies. The browser won’t display anything since the server has not yet sent a response, but check your terminal output for the logged request line.

Building HTTP Responses

The response is assembled from four parts: the start line, header fields, a blank line, and the message body. The start line contains the status code. For successful requests, 201 OK communicates success; for broken requests, the familiar 404 Error informs the client of the problem.

Header fields are key-value pairs, like Content-Type: text/html. Response headers provide the browser with additional metadata—redirect paths, caching directives, cookies, or security details—which the client acts on accordingly.

The body holds the HTML that the browser renders.

A directed network diagram showing the movement of HTTP messages between the user, the Client_Request, the Server Response, and Web app/Server.The user send's a Client Request message to the Web app/Server. The Web app/Server sends the client the Server Response.
HTTP Request and Response message specifications between a client and server.

Handling GET and POST Endpoints

The next iteration of the web application introduces distinct endpoints. The application starts with a hard-coded hash of birthday data (non-persistent—restarting the server loses any added entries). A case statement routes requests based on method token and target path, outlining three paths: GET /show/birthday, POST /add/birthday, and a fallback for any other path.

The GET endpoint responds with “200 OK” and displays the birthday data. The POST endpoint behaves differently. Instead of a simple success response, it returns a “303 See Other” status code to redirect the user to the /show/birthdates page. This response must include the Content-Length header so the server knows exactly how many bytes the request body occupies. Ruby’s built-in uri library decodes the request body into a Ruby hash appended to the birthday list.

The response is constructed according to the HTTP spec: version number and status code in the start line, followed by headers, an empty line, and the body.

Testing with the mirth-3.rb gist:

  1. Run ruby mirth-3.rb.
  2. Open localhost:1337/show/birthdays in your browser.
  3. Submit new birthday data through the form; the page updates with the new entry.
  4. Restart the server with Control-C and run ruby mirth-3.rb again. The added data is gone, replaced by the default set.

Adding File-Based Persistence

A web application that loses its data on every restart is limiting. The most primitive form of persistence—saving to a file—solves this without external dependencies. While we could write a custom plain-text format, Ruby’s built-in PStore library serializes Ruby objects into a file and deserializes them back on read.

The catch is that PStore writes binary data, which is unreadable to humans. YAML::Store, a PStore implementation, provides the same functionality but serializes into the human-readable YAML format.

Testing with the mirth-4.rb gist:

  1. Place mirth-4.rb and mirth.yml in the same folder.
  2. Run ruby mirth-4.rb and open localhost:1337/show/birthdays. The default data appears as before.
  3. Add some data, then restart the application. Your additions persist—the YAML file has been updated.

A Working Web Application, Layer by Layer

Just with Ruby’s standard library, we now have a functional web application: a TCP server handling raw connections, HTTP requests parsed and responded to manually, and file-based data persistence via YAML. Getting here requires understanding low-level networking, the HTTP message format, and basic storage strategies. There are certainly more convenient approaches to building a Ruby web app—but the path to those abstractions only becomes clearer once you have worked through the fundamentals layer by layer.

Handing HTTP Off to Rack

Every web application ultimately needs to parse incoming HTTP requests and construct valid responses. That work is largely the same regardless of the application’s purpose, so rebuilding it for each project is pure overhead. An application server is a program that performs this work on behalf of the web application: it accepts requests, parses them, and passes the results to your code.

For Ruby, the common contract between an application and an application server is the Rack specification. Define your application as a Rack application, and you can run it with any server that implements that spec. We'll use puma here. A Rack application is simply a Ruby object that responds to #call with a single hash argument (the environment) and returns a three-element array:

  • a status code
  • a hash of response headers
  • an array of the response body

This replaces the manual string parsing and socket handling from the earlier example. Rack and the application server handle the network layer and HTTP protocol details, while your code works with native Ruby data structures.

Using Rack::Request

The first step is to create a rack::request object from the environment hash passed into your application’s #call method. This object exposes the parsed request via methods like #get?, #path, and #params. The #params method is particularly useful: it decodes both the query string and the request body into a single Ruby hash, ready to be used for logic like storing user input.

In the previous version of the code, a large case statement determined which endpoint to serve based on manually split request-line segments. That logic is now replaced with calls to these built-in methods, removing a substantial amount of hand-rolled HTTP parsing.

The application still concludes by returning the three-element array—status, headers, and body. The status must be an integer of 200 or greater, the headers a Hash, and the body an object that implements #each. In this example, the body is a simple Ruby Array.

Finally, we run the application through Rack::Handler::Puma, the Puma API for launching a Rack app. Puma creates the TCP socket and manages incoming connections. You can run the full mirth-5.rb example with ruby mirth-5.rb and observe the same behaviour as before, but with far less low-level code.

Using Rack::Response

Once the request side is standardised, we can do the same for responses. Rack provides a rack::response utility that encapsulates the logic of assembling a typical HTTP response. You create a new rack::response object and then mutate it to fit each endpoint.

For example, in a GET endpoint you can append to the body with the #write method. Sensible defaults are applied automatically: the status defaults to 200 and the content type to "text/plain", so you only set them when you need something different. A redirect, which previously required setting two separate values, is now accomplished with a single call that sets both the redirection status and the target path.

Once the response object is fully configured, you call #finish. This wraps everything up and hands the completed response back to Puma to send to the client.

The transformation is evident in the mirth-6.rb example: nearly all the manual assignment of status codes, headers, and body strings has been replaced by library calls.

Moving Beyond Flat Files

A YAML file is simple to get started with, but it doesn't hold up as the application grows. Loading and parsing a large file on every single request is slow in both time and memory, and the structure becomes unwieldy for complex data models. A database offloads query optimisation and data consistency concerns to a dedicated engine.

SQLite is a good fit for a lightweight Ruby application because it dispenses with the client-server model. Instead of running a separate database process, SQLite operates directly within the application process and reads from a local file. The sqlite3 gem provides a Ruby API for this engine, letting us swap out the YAML::Store calls for real database operations.

Setting Up the Database

Before the application code can run, you must seed the database. Create a file and a table along with some initial values from the command line:

sqlite3 mirth.sqlite3
CREATE TABLE birthdays (name TEXT, date TEXT);
INSERT INTO birthdays VALUES("Gma", "2021-01-01");
INSERT INTO birthdays VALUES("Tom", "2021-01-02");
INSERT INTO birthdays VALUES("Sesame", "2021-01-03");
.quit

In the application code, you connect to that table with SQLite3::Database.new, passing the file name. An optional results_as_hash: keyword argument makes the library return rows as Ruby hashes instead of arrays.

Reading and Writing Data

Retrieving the birthday list is a direct query rather than a YAML transaction. The GET endpoint executes SELECT * FROM birthdays and receives all rows as a hash. Note that SELECT * has performance implications for large datasets, but it is fine for this sample.

The POST endpoint, in turn, executes an INSERT query with placeholders for the user-supplied values:

INSERT INTO birthdays (name, date) VALUES(?, ?)

With the placeholders bound to the actual name and birthdate, the sqlite3 gem handles adding the new row to the table. After these changes, the application runs with ruby mirth-7.rb, but now all reads and writes go through SQLite instead of YAML::Store. The sqlite3 library handles persistence in a local file, eliminating the separate parsing step of the YAML store for good.

Replacing Custom Code with Rails Libraries

Rather than pulling in Rails as a monolith, you can swap out parts of your hand-rolled Rack application with individual Rails libraries. Each library covers a distinct concern, and composing them gives you a clearer picture of what Rails itself provides under one umbrella.

Active Record for Database Access

Active Record is Rails' object-relational mapping (ORM) library. It implements the active-record pattern: a Ruby object wraps a row in a database table, encapsulates access to that data, and exposes domain logic on top of it. Instead of hand-writing SQL for CRUD operations, you interact with the database through an object-oriented API where class names map to table names — the model Birthday maps to the pluralized birthdays table in SQLite.

Handling requests becomes more direct. For a GET endpoint, Birthday.all returns an array of Birthday objects, each with helpers for columns like name and birthday. For a POST endpoint, Birthday.create(name:, date:) creates and saves the new row in one call. The library takes over the mechanics of connecting to the Sqlite3 database and matching classes to tables.

Action Dispatch for Routing

Routing is another layer you can extract. The ActionDispatch::Routing::RouteSet class provides a router that processes incoming requests and dispatches them to the appropriate code path, replacing the manual checks for request method and path scattered through the app.

The RouteSet#draw method accepts a block with helper methods named after HTTP verbs (GET, POST) to wire each endpoint to its handler. Instead of one Rack app checking conditions, you end up with a larger Rack application that routes requests through Action Dispatch down to individual mini Rack applications — one per endpoint.

Three separate mini apps still feels clunky though. That's where Action Controller steps in.

Action Controller for Request Handling

Action Controller follows the MVC pattern, acting as the intermediary between the view and the data model. Given a request, the controller decides what data to query and which view to render. The methods defined in ActionController::Base are called "actions," and they take over the role of the mini Rack applications from the routing layer.

A directed network diagram showing the relationship between the user, the Model, View, and Controller. The user kicks off the flow by seeing the View. The View updates the Controller and displays the Model. The Model only accepts input from the View and Controller. The Controller manipulates the Model and it renders the View.
A generic MVC model, where the controller renders the view and updates the data model based on user input.

A BirthdaysController — pluralized, per convention — hosts actions that correspond to HTTP endpoints. While an action like #show_all_birthdays would describe the intent, Rails routing conventions dictate naming: #index for GET /birthdays, #create for POST /birthdays, and so on as defined in the Rails Routing API.

The controller also absorbs utilities that previously came from Rack's Request and Response objects. Instance methods like #params, #render, and #redirect_to handle input, output, and HTTP flow, trimming the code for each endpoint considerably. Beyond request routing, Action Controller offers HTTP authentication, session storage, and exception handling over the application lifecycle.

Action View for Rendering

Once routing and controller logic shift to Action Controller, a large portion of inline HTML disappears from the application code. Action View handles the view side of MVC, generating HTML from embedded Ruby (ERB) templates and handing the result back to the controller for the response.

Templates must follow Rails conventions. The view directory birthdays/index.html.erb holds the ERB file, and the controller locates it via the #prepend_view_path method.

Action View's helper methods replace repetitive boilerplate like form and content tags. Controller instance variables are directly accessible in the view — after the controller action queries all birthdays, the view reads the @all_birthdays instance variable and displays them.

A directed network diagram showing the relationship between the all the Rails components in the Mirth application. The Client uses Puma to send and receive data from Action Dispatch. Action Dispatch sends and receives data from the Action Controller. The Action Controller sends data to Action View. The Action Controller sends and receives data from Action Model. Finally Action Model sends and receives data from SQLite3
Overview of all how the Rails components work together in Mirth.

Running the Composed Application

The complete source, mirth-final.rb, ties these libraries together into a single Rack application. To run it:

  1. Assuming the SQLite3 database from the previous steps exists, place index.html.erb in a birthdays/ directory and run ruby mirth-final.rb.
  2. Visit localhost:1337/birthdays to see the same web application working, now built from Rails libraries rather than raw Rack primitives.

Beyond the Tutorial Basics

The components covered so far are only a slice of what Rails provides. The framework also ships libraries for sending email, orchestrating background jobs, and adding internationalization to an app. Each of those subsystems is itself deep enough for its own detailed walkthrough.

A little Rails familiarity is enough to get a working application up quickly — that is the framework's appeal. Rebuilding the same functionality from scratch in pure Ruby is an educational exercise, and something of a rite of passage for Ruby developers. That said, it is probably not something you want to do in production. Having seen the low-level plumbing — what happens between a raw TCP connection and a rendered response — it becomes clear why Rails deliberately hides so much of that machinery.

Further Reading

The following resources cover the libraries and concepts referenced throughout this tutorial: