The Complete Journey of a Request Before It Reaches Your Backend
A Deep Dive into How a Request Travels Through the Network, OS, and Application Before Reaching Your Backend Logic
In this blog, we will explore the detailed path of a request from the moment it leaves the client (front-end or another service) until it enters your backend application’s user space. We often focus on the final arrival of an HTTP request handler (like an http.HandlerFunc
in Go or the “on request” event in Node.js), but beneath that, there is a wealth of complexity.
Why should you care about these hidden steps? Because understanding them lets you troubleshoot performance issues, handle security concerns better, and scale more wisely. Too many engineers simply add more servers when facing bottlenecks—sometimes prematurely—without pinpointing the real cause (like slow acceptance, TLS overhead, partial reading, or inefficient parsing).
Below, we detail each step:
Accept – How the operating system kernel and your code handle the new connection, from
SYN
packets to accept queues.Read – How raw bytes flow from the kernel’s receive queue into your application.
Decrypt – Unveiling TLS overhead and CPU usage.
Parse – Extracting structure from raw streams (e.g., HTTP/1.1, HTTP/2, or custom protocols).
Decode – Translating the parsed data (e.g., JSON, form data, or UTF-8 strings).
Process – Where your real backend logic finally kicks in.
By the time your app sees a “request object,” countless steps have quietly happened under the hood. Let’s take a deep dive.
A Quick Note on What “Request” Really Means
In networking terms, a “request” is a unit of work or an operation that a client or frontend (which could be a web browser, a mobile app, or another microservice) sends to your server. This request is typically governed by a protocol that defines how to start and end a request, what headers or metadata exist, and how the body is structured.
The most common protocol is HTTP (in flavors like HTTP/1.1, HTTP/2, HTTP/3 via QUIC, etc.). But you can define your own. For instance:
A custom binary protocol with fixed-size requests.
A streaming protocol with special delimiters.
JSON-based protocols layered on top of websockets.
All these differences matter because they influence how you parse, decode, and handle data. Even if you never write your own protocol, understanding how existing ones (like HTTP) work helps you troubleshoot real-world issues.
Step 1: Accept
1.1 The Kernel’s Role in Accepting Connections
Before your backend process “knows” about a connection, the OS kernel does several things:
You listen on a port (e.g., 8080). Internally, Linux (or another OS) creates a socket (which is effectively a file descriptor) associated with that port.
The kernel allocates two special queues for that listening socket:
SYN Queue: Holds half-open connections in the midst of the TCP three-way handshake (after receiving a
SYN
but before finalizingSYN/ACK
→ACK
).Accept Queue: Holds fully established connections (those that have passed the entire handshake).
When a client sends a SYN
packet to your server, the kernel places it in the SYN Queue, replies with SYN/ACK
, and waits for the client’s ACK
. If the client acknowledges, that connection moves to the Accept Queue.
1.2 Accepting in the Application
In Go, the code to listen and accept connections looks deceptively simple:
package main
import (
"fmt"
"net"
)
func main() {
// Listen on TCP port 8080
ln, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
fmt.Println("Server is listening on port 8080...")
for {
// Accept a new connection from the Accept Queue
conn, err := ln.Accept()
if err != nil {
fmt.Println("Error accepting connection:", err)
continue
}
fmt.Println("New connection accepted!")
go handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
// We will handle reading, decrypting, etc., in later steps
defer conn.Close()
// For now, let's just hold the connection open briefly
fmt.Println("Handle connection here. The actual read steps come next.")
}
Notice:
net.Listen("tcp", ":8080")
: Opens the socket and sets up the accept queues.ln.Accept()
: Pulls a connection from the Accept Queue. If the Accept Queue is empty,Accept()
blocks until new connections arrive.
1.3 Tuning the Backlog
When you call ln, _ := net.Listen("tcp", ":8080")
, you can (in lower-level APIs) specify a “backlog” parameter. This controls how many connections can accumulate in the Accept Queue. If the backlog is exceeded, new SYN
s might be dropped or delayed. On some systems, you can tune it via:
sysctl -w net.core.somaxconn=1024
(A typical default might be 128 or 256, which can be too small if you experience heavy bursts.)
1.4 Multiple Acceptors and Scaling
You can accept connections in multiple goroutines or even multiple processes. In Linux, you might do:
Set
SO_REUSEPORT
to allow multiple processes to listen on the same port, distributing load.In Go, you might have multiple goroutines calling
Accept()
in parallel, although Go’s standard library by default uses a singleAccept()
loop.
This addresses scenarios with extremely high connection rates, but it also complicates concurrency.
Step 2: Read
2.1 TCP Streams and Kernel Buffers
Once a connection is established, the client can start sending data. This data arrives at your server’s network interface and is moved into the kernel’s receive queue for that specific connection.
Each connection has its own “receive queue” where packets (TCP segments) are buffered.
The OS acknowledges each segment (TCP handles acknowledgment, retransmission, window sizes, etc. at the kernel level).
2.2 Copy to User Space
Your Go code eventually calls something like:
package main
import (
"fmt"
"net"
"os"
)
func main() {
ln, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("Error setting up listener:", err)
os.Exit(1)
}
fmt.Println("Listening on :8080...")
for {
conn, err := ln.Accept()
if err != nil {
fmt.Println("Accept error:", err)
continue
}
go handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
defer conn.Close()
// Read up to 1024 bytes
buffer := make([]byte, 1024)
n, err := conn.Read(buffer)
if err != nil {
fmt.Println("Error reading from connection:", err)
return
}
fmt.Printf("Read %d bytes: %v\n", n, buffer[:n])
// In real scenarios, we'd do more logic here
// ...
}
When conn.Read
(buffer)
executes, it copies data from the kernel receive buffer into your user-space buffer (buffer
). This is a blocking call unless there’s data available.
2.3 Partial Reads and Protocol Considerations
TCP is a stream protocol, not message-based. This means:
You might read 50% of one request, or 1.5 requests if the client pipelined them, or no data at all if it hasn’t arrived yet.
Your code typically loops until you confirm you have a complete request.
In many frameworks, you do not see this loop because the library handles it. However, under the hood, it’s doing exactly that.
2.4 Performance Implications
If your code is slow to read data, the kernel’s receive buffer may fill up. This can lead to TCP backpressure (window size reduction, potential retransmissions). In high-load systems, reading quickly and properly is crucial to maintain throughput.
Also consider your concurrency model:
If you have thousands of connections, each might be in a separate goroutine or thread waiting to read.
If you have insufficient concurrency, new connections might starve waiting for CPU cycles.
Step 3: Decrypt (Optional, but Common with HTTPS)
3.1 TLS Handshake Basics
If you are running HTTPS or any TLS-encrypted protocol, the handshake (key exchange) has already taken place after the Accept step but before actual data is read as plaintext. This handshake involves:
Client and server agreeing on TLS version, ciphers.
Exchanging keys securely (asymmetric cryptography).
Establishing a symmetric key for ongoing encryption.
3.2 CPU Overhead of Decryption
Each packet is encrypted by the client and must be decrypted by your server. This typically uses algorithms like AES (in GCM mode, CBC, or others). Decryption is CPU-expensive compared to plain TCP. Under heavy loads (thousands of concurrent HTTPS connections), CPU usage can spike just from encryption/decryption tasks.
3.3 Go Example with TLS
In Go, you might do something like:
package main
import (
"crypto/tls"
"fmt"
"net"
"os"
)
func main() {
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
fmt.Println("Error loading TLS certificate:", err)
os.Exit(1)
}
tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}}
// Listen on TLS port 8443
ln, err := tls.Listen("tcp", ":8443", tlsConfig)
if err != nil {
fmt.Println("Error listening on TLS port:", err)
os.Exit(1)
}
fmt.Println("TLS server listening on port 8443...")
for {
conn, err := ln.Accept()
if err != nil {
fmt.Println("Error accepting TLS connection:", err)
continue
}
go handleTLSConnection(conn)
}
}
func handleTLSConnection(conn net.Conn) {
defer conn.Close()
// Under the hood, conn.Read() is now reading encrypted data
// from the kernel and decrypting it into plaintext
buffer := make([]byte, 2048)
n, err := conn.Read(buffer)
if err != nil {
fmt.Println("Error reading from TLS connection:", err)
return
}
fmt.Printf("Decrypted %d bytes: %s\n", n, string(buffer[:n]))
// We can proceed with parse/decode steps on the plaintext data
}
Notice that we don’t manually handle encryption or decryption. The Go TLS library does it automatically. But the CPU overhead is still your server’s responsibility.
3.4 Memory Copies and Modern Solutions
Data is typically copied multiple times (kernel memory → user space encrypted buffer → user space decrypted buffer). Innovations like io_uring aim to reduce these copies in Linux, but they’re still in relatively early adoption.
Step 4: Parse
4.1 Parsing Protocol Structures
Now that you (potentially) have plaintext data in user space, the next step is to figure out how to interpret it. For HTTP/1.1:
You look for the request line, e.g.
GET / HTTP/1.1
.Then, you parse headers (one per line) until you reach a blank line.
If there’s a Content-Length header, you know how many bytes of body to read. If it’s chunked encoding, you parse chunk by chunk.
With HTTP/2, parsing is more involved because data is framed. You must read frame headers, handle stream identifiers, window updates, etc.
Every protocol has to define how to separate “packets” or “requests” from the raw byte stream. This is called message framing.
4.2 Example: Naive Parsing in Go
Here’s a complete example that very naively parses a single HTTP/1.1 request. It reads once, looks for the request line and headers, and prints them. (In a real server, you must handle partial reads, chunked bodies, etc.)
package main
import (
"fmt"
"net"
"os"
"strings"
)
func main() {
ln, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("Error setting up listener:", err)
os.Exit(1)
}
fmt.Println("Naive HTTP Parser listening on :8080")
for {
conn, err := ln.Accept()
if err != nil {
fmt.Println("Error accepting connection:", err)
continue
}
go parseHTTPConnection(conn)
}
}
// parseHTTPConnection attempts to parse an HTTP/1.1 request from a single read
func parseHTTPConnection(conn net.Conn) {
defer conn.Close()
buffer := make([]byte, 4096)
n, err := conn.Read(buffer)
if err != nil {
fmt.Println("Error reading:", err)
return
}
requestData := string(buffer[:n])
fmt.Println("Raw HTTP data received:")
fmt.Println(requestData)
// Split into lines
lines := strings.Split(requestData, "\r\n")
if len(lines) < 1 {
fmt.Println("No valid HTTP line found")
return
}
// First line is usually "METHOD PATH HTTP/1.1"
requestLine := lines[0]
fmt.Println("Request Line:", requestLine)
// Print headers
i := 1
for ; i < len(lines); i++ {
if lines[i] == "" {
// empty line -> end of headers
break
}
fmt.Println("Header:", lines[i])
}
// i now points to the blank line after headers
// If there's more data, it might be the body.
if i < len(lines)-1 {
body := strings.Join(lines[i+1:], "\r\n")
fmt.Println("Body:", body)
}
// Send a very simple response
response := "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello from naive parser!\r\n"
conn.Write([]byte(response))
}
Step 1 & 2 (Accept, Read) are integrated.
We skip Step 3 (Decrypt) because it’s plain TCP.
Step 4: We parse the HTTP request line and headers.
This is oversimplified but shows how you might approach protocol parsing. In production, you rely on robust libraries (net/http
, nghttp2
, etc.).
4.3 CPU Costs of Parsing
Parsing can be CPU-intensive if you have:
Very large headers.
Complex structured data (like HTTP/2 frames).
Many simultaneous connections.
HTTP/2 is more expensive than HTTP/1.1 because it uses compression for headers (HPACK or QPACK in HTTP/3) and frames. This is beneficial for multiplexing but costs more CPU to handle.
Step 5: Decode
5.1 Beyond Parsing: Decoding the Body
Once the protocol is parsed, you likely have a body in raw text or binary. Now you must decode or deserialize it into something meaningful for your application.
JSON or XML: Convert from raw text to structured data (maps, structs, etc.).
Binary protocols (like Protobuf, MessagePack): Convert from binary frames to objects.
Form data: Parse
application/x-www-form-urlencoded
ormultipart/form-data
.
This can be an expensive operation if the body is large or if you do repeated decoding.
5.2 JSON Example
Below is a complete sample that reads a raw HTTP-like request, extracts a JSON body, and decodes it into a Go struct:
package main
import (
"encoding/json"
"fmt"
"net"
"os"
"strings"
)
type Payload struct {
Name string `json:"name"`
Email string `json:"email"`
}
func main() {
ln, err := net.Listen("tcp", ":8080")
if err != nil {
fmt.Println("Error setting up listener:", err)
os.Exit(1)
}
fmt.Println("Listening on :8080 for JSON decode example...")
for {
conn, err := ln.Accept()
if err != nil {
fmt.Println("Error accepting connection:", err)
continue
}
go handleJSONConn(conn)
}
}
func handleJSONConn(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
fmt.Println("Error reading:", err)
return
}
// Very naive parse again
requestData := string(buf[:n])
lines := strings.Split(requestData, "\r\n")
// Find blank line to separate headers from body
bodyIndex := -1
for i, line := range lines {
if line == "" {
bodyIndex = i + 1
break
}
}
if bodyIndex == -1 || bodyIndex >= len(lines) {
fmt.Println("No body found in request")
return
}
body := strings.Join(lines[bodyIndex:], "\r\n")
// Step 5: Decode JSON
var payload Payload
err = json.Unmarshal([]byte(body), &payload)
if err != nil {
fmt.Println("JSON decoding error:", err)
return
}
fmt.Printf("Decoded JSON: Name=%s, Email=%s\n", payload.Name, payload.Email)
// Respond
response := "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nReceived JSON. Thank you!\r\n"
conn.Write([]byte(response))
}
Parsing: We look for headers, find the blank line, treat the rest as the body.
Decoding: We use
json.Unmarshal
to parse into aPayload
struct.
5.3 Watch Out for Large JSON
Large or nested JSON objects can balloon CPU usage.
If you do repeated decoding in hot loops, it can slow your server drastically.
Also be mindful of memory usage. Some languages and frameworks use streaming parsers to avoid loading the entire body in memory at once.
Step 6: Process
Finally, after all the above steps, you have identified a complete request and turned it into application-friendly data (like a struct, an object, or key-value pairs). Now you can do the real logic:
Business Rules: E.g., “Retrieve top 10 books,” “create a user,” “apply a discount.”
Database Access: The backend becomes a “client” to the database server, ironically repeating many of these steps (SYN, Accept, parse, decode) at the DB side.
Send a Response: The response might also get encoded (e.g., JSON), parsed into frames (e.g., HTTP/2 frames), then encrypted if using TLS, and finally written to the kernel send queue, traveling back to the client.
6.1 CPU-Bound vs. IO-Bound
Your processing might be:
CPU-Bound: e.g., cryptographic tasks, image processing, big data computations in memory.
IO-Bound: e.g., calling out to external services, databases, or reading large files from disk.
Understanding whether your requests are CPU- or IO-bound shapes how you scale (number of worker goroutines, concurrency patterns, etc.).
6.2 Handling Multiple Requests
Modern servers often handle many concurrent requests. In Go, concurrency via goroutines is relatively light. However, each request flow includes:
Accepting a connection.
Possibly reading partial data many times.
Decrypting if TLS.
Parsing and decoding.
Processing.
If you reach thousands of concurrent requests, you might saturate:
CPU: If cryptography or parsing is heavy.
Memory: If requests are large.
Network: If your throughput is enormous.
Optimizing each step is essential before deciding to spin up more machines.
Why Understanding These Steps Matters
Performance Tuning:
If your bottleneck is accepting connections, tune backlog or parallel acceptance.
If your bottleneck is in TLS decryption, consider optimizing ciphers, using hardware acceleration, or carefully analyzing concurrency.
If your bottleneck is parsing or decoding, analyze your library or consider partial/parsing streaming.
Security:
Know how and where your private keys are used (in memory, in a secure TPM, etc.).
Understand that any buffer containing sensitive data (after decryption) might leak if there is a bug (e.g., Heartbleed-type vulnerabilities).
Troubleshooting:
Dropped connections? Possibly backlog overflow or ephemeral ports are exhausted.
Slow responses? Possibly waiting for partial data or stuck in a CPU-heavy decode.
High CPU usage? Possibly from TLS overhead or large JSON parsing.
Scalability:
Sometimes you do not need 10 extra servers if you find that your “Decode” step is the real problem (maybe it’s slow or done repeatedly).
Understanding each stage fosters more informed decisions about load balancing, caching, concurrency, or even rewriting certain parts in a more efficient manner.
Example: A “Full” (Yet Simplistic) Go Server Demonstrating All Steps
Below is a single program that tries to incorporate all six steps in one place. In real production, you would likely use net/http
or another fully-featured library, but let’s do it manually to illustrate the entire journey:
package main
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"os"
"strings"
)
// Let's define a sample JSON payload structure
type SampleRequest struct {
Action string `json:"action"`
Data string `json:"data"`
}
// main function sets up a TLS listener to show the whole journey
func main() {
// Load TLS certificate and key (assumes server.crt and server.key are present)
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
fmt.Println("Error loading certificates:", err)
os.Exit(1)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
}
// Step 1: Accept
// Listen on TLS port 8443
ln, err := tls.Listen("tcp", ":8443", tlsConfig)
if err != nil {
fmt.Println("Error listening on port 8443:", err)
os.Exit(1)
}
fmt.Println("TLS Server listening on port 8443...")
for {
conn, err := ln.Accept()
if err != nil {
fmt.Println("Error accepting connection:", err)
continue
}
// Handle each connection in a separate goroutine
go handleTLSConn(conn)
}
}
func handleTLSConn(conn net.Conn) {
defer conn.Close()
// Step 2: Read (the data is initially encrypted, but net.Conn is now a TLS conn)
// Under the hood, Step 3 (Decrypt) is happening automatically, so by the time
// we read from conn, we get plaintext.
buffer := make([]byte, 4096)
n, err := conn.Read(buffer)
if err != nil && err != io.EOF {
fmt.Println("Read error:", err)
return
}
// We might get partial data or entire request; let's assume it's enough for illustration
plaintext := string(buffer[:n])
fmt.Println("Received (decrypted) data:", plaintext)
// Step 4: Parse naive HTTP
lines := strings.Split(plaintext, "\r\n")
if len(lines) < 1 {
fmt.Println("No valid HTTP request line found.")
return
}
// The first line should be something like: "POST / HTTP/1.1"
fmt.Println("Request line:", lines[0])
// Find blank line to separate headers from body
blankLineIndex := -1
for i, line := range lines {
if line == "" {
blankLineIndex = i
break
}
}
if blankLineIndex == -1 {
fmt.Println("Could not find blank line for body separation.")
return
}
// Step 5: Decode JSON from the body
bodySlice := lines[blankLineIndex+1:]
body := strings.Join(bodySlice, "\r\n")
var req SampleRequest
err = json.Unmarshal([]byte(body), &req)
if err != nil {
fmt.Println("JSON Unmarshal error:", err)
return
}
fmt.Printf("Decoded JSON: Action=%s, Data=%s\n", req.Action, req.Data)
// Step 6: Process - let's do something trivial, like just print
responseMsg := fmt.Sprintf("Action: %s, Data: %s - Processed!\n", req.Action, req.Data)
fmt.Println("Processing complete. Sending response...")
// Form a basic HTTP 200 response
response := "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n" + responseMsg
// We write the response back, which will be encrypted again by TLS
_, writeErr := conn.Write([]byte(response))
if writeErr != nil {
fmt.Println("Error writing response:", writeErr)
}
}
Explanations:
Accept:
tls.Listen("tcp", ":8443", tlsConfig)
sets up a TLS listener, andAccept()
pulls connections off the Accept Queue.Read: We call
conn.Read
(buffer)
to copy from kernel buffer → user space.Decrypt: Under the hood, the TLS library is decrypting for us. We see plaintext in
plaintext
.Parse: We do a naive parse of the first line (method, path, version) and find where headers end (
\r\n\r\n
).Decode: We take the body portion and unmarshal JSON into
SampleRequest
.Process: Here, we just print out the request data. In real scenarios, you’d do DB queries, business logic, etc., then we form an HTTP response and send it back.
Notice how many steps are hidden by libraries. If you used the standard net/http
library, you would see just http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request){...})
, but behind the scenes, the library is doing everything we described.
Final Thoughts
There is a vast amount of hidden complexity before your backend sees a “request.” Each of the six steps—Accept, Read, Decrypt, Parse, Decode, and Process—can itself be a bottleneck or a source of errors.
Key Points to Remember
Accept: The kernel must handle the TCP three-way handshake, manage the Accept Queue, and eventually give your application a new socket/file descriptor. If your accept loop is slow, or the backlog is too small, you drop connections.
Read: Data is stored in the kernel’s receive queue, and you must copy it into user space. Partial data is normal in TCP, so robust code typically loops until it has what it needs.
Decrypt: With HTTPS, decryption can heavily load the CPU. Tools like hardware acceleration or efficient ciphers can help.
Parse: Understanding the protocol (HTTP/1.1 vs. HTTP/2 vs. custom) is crucial. HTTP/2 frames, for instance, are more complex than HTTP/1.1 lines. Parsing overhead can be non-trivial.
Decode: Once you have the protocol-level structure, you still need to transform it (JSON, XML, Form data) into an in-memory format. This step can be memory- and CPU-intensive for large bodies.
Process: Finally, your application logic (database queries, CPU tasks, etc.) occurs. This is often the step we focus on, but by then, a lot has already happened.
By shining light on these stages, you can identify where your bottlenecks truly lie. Rather than always scaling horizontally with more machines, you might discover that you can optimize your acceptance strategy, tune your TLS settings, or handle decoding more efficiently.
Additional Tips and Insights
TLS Offloading: In some architectures, you offload TLS to a load balancer (like an Nginx or HAProxy). This spares your backend from CPU encryption/decryption. But it’s still happening somewhere, and you still have to consider overhead.
Node.js vs Go: Node’s single-threaded event loop means one thread calls
accept
and handles read events asynchronously. Go’s concurrency model uses goroutines (multiplexed on threads). The underlying concepts, however, remain the same (SYN queue, Accept queue, kernel buffers).Large Payloads: If you expect large JSON or file uploads, consider streaming. Instead of reading everything into memory, you can process chunks as they arrive. This approach lowers memory usage and can reduce latency for large payloads.
HTTP/2 Gains & Costs**: HTTP/2 multiplexing can reduce the number of TCP connections, but the protocol overhead and header compression can increase CPU usage. Evaluate these trade-offs based on your traffic patterns.
Performance Monitoring: Tools like
strace
,tcpdump
, or application-level profiling (pprof
in Go) can help pinpoint if you’re CPU-bound (in JSON parsing or TLS) or if you’re stuck in slow I/O.
Conclusion
We’ve seen how every request travels through multiple stages before your code calls it a “request.” By recognizing the Accept → Read → Decrypt → Parse → Decode → Process flow, you get greater visibility into why performance, security, or scaling problems may arise.
Some steps (like Accept and Read) are heavily dependent on the operating system’s kernel and network stack.
Others (like Decrypt and Parse) rely on CPU and library efficiency.
The final user-land steps (like Decode and Process) reflect how you interpret data and apply your business logic.
When trouble hits, or you need to scale, you now have the mental framework to say, “Okay, which step is the real culprit?” Sometimes, small adjustments—like increasing the Accept backlog, switching to more efficient JSON decoding, or caching TLS sessions—can yield huge gains.
Thank you for reading, and happy engineering!