The biggest smallest website
Introduction
I was surfing the web and, as is often the case, I stumbled upon a cool project: the FastestWebsiteEver. It’s “the greatest website to ever fit in a single TCP packet.”
I had a think about that for a minute, and checked out the actual site, and noticed that it’s approximately 1130 bytes transferred. Now depending on how fresh you are on your OSI Model, you might remember that there are 7 layers. The data that fits into a single transmissable unit in one layer may be too big for one of the other layers to transmit as a single unit, resulting in fragmentation and reassembly. I thought 1130 bytes sounded rather large for something guaranteed to fit into a single packet (although 1500 bytes is standard MTU for Ethernet), so I decided it might be fun to dig up the old RFCs and have a look at what the TCP and IP specifications say.
TCP and IP RFCs
One of the original TCP/IP specs, RFC 879, has been updated by RFC 6691. In this RFC, we learn that:
THE TCP MAXIMUM SEGMENT SIZE IS THE IP MAXIMUM DATAGRAM SIZE MINUS FORTY.
Well, the maximum IP datagram size is 576 bytes, so we know that the maximum segment size (MSS) for TCP is 536 bytes. This leaves 20 bytes for minimal TCP headers, and 20 bytes for minimal IP headers. If we send an IP datagram of 536 bytes, we are guaranteed that the datagram (and the resulting TCP segment) will not be fragmented and will be delivered in a single unit.
Okay. Let’s build a website that fits into 536 bytes!
HTTP RFC
First, we don’t need a full-blown HTTP server for this. We just need a TCP server that responds with a string of bytes containing the smallest valid HTTP response, and our website content, all of which has to be less than 536 bytes. We have to respond with a valid HTTP response, but what is that, exactly?
Well, RFC 2616 is pretty clear about the format of an HTTP response:
Response = Status-Line ; Section 6.1
*(( general-header ; Section 4.5 | response-header ; Section 6.2 | entity-header ) CRLF) ; Section 7.1 CRLF [ message-body ] ; Section 7.2
That means that the only required element is the the Status-Line.
Status-Line = HTTP-Version SP Status-Code SP Reason-Phrase CRLF
Easy enough. The smallest thing we could send as a response from our TCP server is HTTP/1.1 200 OK\r\n
. That response string is 17 bytes, which cuts our available bytes for the message body down to 519 bytes. As a reminder of the accounting: we can send 576 bytes total in a single TCP segment, and we must reserve 20 for TCP header, 20 for IP header. This leaves us 536 bytes for everything else. We need another 17 bytes for the HTTP response, giving us 536-17=519 bytes.
Building a TCP server
Writing a TCP server is pretty simple in many languages, but I chose to do it in Go.
package main
import (
"bufio"
"net"
)
func main() {
ip := net.IPv4(0, 0, 0, 0)
tcpaddr := net.TCPAddr{IP: ip, Port: 8080}
ln, _ := net.ListenTCP("tcp", &tcpaddr)
httpResponse := []byte("HTTP/1.1 200 OK\r\n")
for {
conn, _ := ln.AcceptTCP()
conn.SetNoDelay(true) // Silly windows are silly. See Nagling.
go func(){
defer conn.Close()
_, _, _ = bufio.NewReader(conn).ReadLine() // This is used in the stdlib HTTP server
conn.Write(httpResponse)
}()
}
}
Most of that is straightforward, but there is one small item which may seem unexpected: conn.SetNoDelay(true)
. What’s that about?
Nagling: an aside
In the old days, before people knew how to write efficient networked applications (wait, is that not true anymore…?), it was sometimes the case that a TCP packet would need to be transmitted with a very small payload. As a pathological example, consider a payload of 1 byte. This would require at least 20 bytes for the IP header, and 20 bytes for the TCP header, just to send 1 byte of data. Since having 40 to 1 overhead for data transmission is bad, John Nagle decided it would be a good idea to batch the sending of TCP packets. Hence, we have Nagle’s Algorithm.
The short version is that if you can fit more data into the TCP segment, wait and accumulate before sending anything. This prioritizes throughput over latency.
For our purposes, since we want the lowest latency and we are carefully constructing our TCP segment, we can disable Nagling on our TCP connections. This is accomplished in Go by setting SetNoDelay(true)
on our connections as they come in.
The content
Now that we have a working server that responds to TCP requests with a valid HTTP response, we can add some content using our remaining 519 bytes. Since 1 ASCII character is 1 byte, that doesn’t leave us much to work with. It’s not even 4 tweets worth of text (well, it’s 1.85 tweets after the new limit rolls out). However, we can compress the content before we send it, allowing us to fit much more into the same 519 bytes!
The downside to using compression is that we have to tell the HTTP client about it. We’ll need to add an encoding header to our HTTP response, which will take up some bytes. In the old days, it was possible to send raw DEFLATE encoded data to a browser and have it do the decoding by default, but those days are gone now.
We’ll have to put our header on a new line, which means we will need to take two bytes for a \r\n
in addition to the header itself. After our header, Content-Encoding: deflate
, we also need an additional \r\n
at the end of our HTTP response. In total, the ability to compress the response costs us another 29 bytes. Now we’re down to 490 bytes for content, but it’s for compressed content. Using DEFLATE, we can probably reduce the size by 50% after compression, so if we start with about 1000 bytes, we should be able to compress down to 490.
In the meantime however, here’s the updated server with compression:
package main
import (
"bufio"
"bytes"
"compress/flate"
"log"
"net"
)
func main() {
ip := net.IPv4(0, 0, 0, 0)
tcpaddr := net.TCPAddr{IP: ip, Port: 8080}
ln, _ := net.ListenTCP("tcp", &tcpaddr)
httpResponse := []byte("HTTP/1.1 200 OK\r\nContent-Encoding: deflate\r\n\r\n")
resp := "Hello, world!"
var buf bytes.Buffer
zw, err := flate.NewWriter(&buf, flate.BestCompression)
if err != nil {
log.Fatal(err)
}
_, err = zw.Write([]byte(resp))
if err != nil {
log.Fatal(err)
}
zw.Close() // In case buffers need to be flushed
compressedBody := buf.Next(buf.Len())
for {
conn, _ := ln.AcceptTCP()
conn.SetNoDelay(true) // Silly windows are silly. See Nagling.
go func(){
defer conn.Close()
_, _, _ = bufio.NewReader(conn).ReadLine() // This is used in the stdlib HTTP server
conn.Write(append(httpResponse, compressedBody...))
}()
}
}
Fin
From that point, it’s a matter of extending resp
until you have the response body you want while still coming in under the 536 total bytes allowed for a single TCP segment. If you wanted to be extra careful, you could add an if statement to check that the length of compressedBody
is less than 536 (the IP datagram body size limit), and exit the program if it isn’t.
If you find any issues with the code above or notice something I misinterpreted about the RFCs, do contact me so I can update things.
If you want to see the biggest smallest site, you can visit it at tinysite.adamdrake.com.
Lastly, I also did a quick TCP server in Rust as well to see if the performance was any better than the Go version, but there wasn’t a big difference in this case.
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
fn main() {
let listener = TcpListener::bind("127.0.0.1:9123").unwrap();
println!("listening started, ready to accept");
for stream in listener.incoming() {
thread::spawn(|| {
let mut stream = stream.unwrap();
let _ = stream.read(&mut [0; 128]);
stream.write(b"Hello World\r\n").unwrap();
});
}
}