← Networking
Technique Networking foundations

TCP connection lifecycle

A successful connect is one milestone in a longer conversation.

A service log says a client connected, then the client reports that its request failed. At the same time, an operator sees many TIME-WAIT sockets and suspects a leak. Follow one connection from its first SYN to its final close to see what each observation can establish—and what it cannot.

Follow one conversation

Separate transport state from application meaning. Then use the packet and socket evidence at the right boundary.

TypeScriptGo Handshake · byte stream · close · reset
01 / Start with the evidence

“Connected” means the transport handshake completed.

For TCP, a client’s successful connect means the local stack established a connection with a peer endpoint. It does not prove that TLS negotiation succeeded, that the peer parsed a request, or that the application returned a useful response. Those are later steps, with their own evidence.

Record the endpoint pair, timestamp, process, and the exact event that was observed. A client log, server log, socket table, and packet capture each describe a different vantage point. Align them before treating a gap between two events as proof that a specific component dropped the connection.

02 / Establish transport state

The handshake synchronizes state at both endpoints.

The usual open begins with a client SYN, a server SYN-ACK, and a client ACK. Sequence numbers let both TCP implementations track ordered byte positions. The final ACK usually completes establishment, though packet captures can show data accompanying an acknowledgment or packet aggregation that makes the exchange look different.

01 · clientSYN

Requests a connection and proposes its initial sequence number.

02 · serverSYN + ACK

Acknowledges the client sequence and proposes its own.

03 · clientACK

Acknowledges the server sequence; the connection is established.

04 · bothApplication data

Now the transport can carry bytes in either direction.

03 / Follow the byte stream

TCP preserves byte order, not your write boundaries.

If an application writes "HEL" and then "LO\n", the receiving program might read both together, in smaller pieces, or split at another byte. TCP presents an ordered stream. The application protocol must define where a message ends—for example, with a length prefix, delimiter, or connection close.

Likewise, a packet capture shows transport segments at one capture point. Offload features can change how host captures appear, and segmentation does not map one-to-one to application reads. Diagnose the protocol framing at the application layer and use captures to answer transport questions.

Same bytes · different readsTwo writes are one application stream
Sender writes
HEL then LO\n
Receiver may read
HELLO\n, HE + LLO\n, or another split
Application needs
A message boundary rule and a loop that handles partial reads.
04 / Close each direction

FIN closes one sending direction; the other direction can continue.

TCP is full duplex. When a client has finished sending its request, it can send FIN while continuing to read. The server observes end-of-input on that direction, sends its response, and then closes its own sending direction. This is a half-close: each side has an independent byte stream and close state.

The endpoint that actively closes a connection commonly passes through TIME-WAIT after the orderly exchange. It waits so delayed segments from the old connection cannot be mistaken for a new one and so the final acknowledgment can be retransmitted if needed. Either endpoint can be the active closer; the role depends on which side sends the first FIN.

Client → serverRequest bytes

The application sends its request.

Client → serverFIN

Client will send no more bytes.

Server → clientResponse bytes

Server can still finish the reply.

Both directionsFIN / ACK

Orderly shutdown; the active closer enters TIME-WAIT.

05 / Separate reset from refusal

A reset interrupts a connection; a refusal usually rejects the open.

A TCP RST abruptly rejects or aborts state. It can appear when a listener is absent, an application aborts a socket, or a network device rejects traffic, depending on the point and circumstances. “Connection refused” commonly corresponds to an active rejection during connection setup. A timeout means no conclusive response arrived before the caller’s deadline.

These errors narrow the next question, but they do not uniquely name the cause. Compare client error, server listener state, route and firewall evidence, and packet timing. A middlebox may generate a response that looks like it came from the destination.

Start with the observed symptom, then verify at the relevant boundary
ObservationWhat it suggestsNext evidence
Connect refusedAn endpoint actively rejected the open.Check listener address and port; identify whether a proxy or firewall answered.
Reset after connectEstablished state was aborted somewhere on the path.Correlate RST timing with application logs and a capture at each available side.
Connect timed outNo usable response reached the caller before its deadline.Check the route, filtering, peer health, and timeout budget from that client.
06 / Run one local conversation

Watch a real loopback connection half-close.

The paired examples open an ephemeral listener on 127.0.0.1. The client connects, sends HELLO, and closes only its write side. The server observes EOF on that direction, sends READY, and closes. No public host or external target is involved.

Compare the same conversation in TypeScript and Go.

Both programs use loopback and demonstrate EOF after a half-close.

TypeScriptLoopback TCP lifecycle · handshake, bytes, half-close, reply
conversation.ts
import { createConnection, createServer, type AddressInfo } from 'node:net';

async function main() {
	// This example binds only to loopback and sends no traffic beyond this process.
	const server = createServer((socket) => {
		socket.setEncoding('utf8');
		console.log(`server accepted one local connection from port ${socket.remotePort}`);
		socket.on('data', (chunk) => {
			console.log(`server received application bytes: ${JSON.stringify(chunk)}`);
		});
		socket.on('end', () => {
			console.log('server observed the client FIN; its write side is still open');
			socket.end('reply: READY\n');
		});
	});

	await new Promise<void>((resolve, reject) => {
		server.once('error', reject);
		server.listen(0, '127.0.0.1', resolve);
	});

	const address = server.address() as AddressInfo;
	const client = createConnection({ host: '127.0.0.1', port: address.port });
	client.setEncoding('utf8');
	client.on('connect', () => {
		console.log('client connect event: the TCP handshake completed');
		// end(data) writes these bytes and then half-closes the client write side.
		client.end('HELLO\n');
	});
	client.on('data', (chunk) => {
		console.log(`client received application bytes: ${JSON.stringify(chunk)}`);
	});
	client.on('end', () => {
		console.log('client observed the server FIN after reading the reply');
	});

	await new Promise<void>((resolve, reject) => {
		client.once('close', () => resolve());
		client.once('error', reject);
	});
	await new Promise<void>((resolve, reject) => {
		server.close((error) => (error ? reject(error) : resolve()));
	});
}

void main().catch((error: unknown) => {
	console.error(error);
	process.exitCode = 1;
});
GoLoopback TCP lifecycle · handshake, bytes, half-close, reply
conversation.go
package main

import (
	"fmt"
	"io"
	"net"
)

func main() {
	// This example binds only to loopback and sends no traffic beyond this process.
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	defer listener.Close()

	serverDone := make(chan error, 1)
	go func() {
		serverSide, err := listener.Accept()
		if err != nil {
			serverDone <- err
			return
		}
		defer serverSide.Close()

		payload, err := io.ReadAll(serverSide)
		if err != nil {
			serverDone <- err
			return
		}
		fmt.Printf("server received application bytes: %q\n", payload)
		fmt.Println("server observed the client FIN; its write side is still open")
		_, err = io.WriteString(serverSide, "reply: READY\n")
		serverDone <- err
	}()

	client, err := net.Dial("tcp", listener.Addr().String())
	if err != nil {
		panic(err)
	}
	defer client.Close()
	clientTCP := client.(*net.TCPConn)
	fmt.Println("Dial returned: the TCP handshake completed")

	if _, err := io.WriteString(clientTCP, "HELLO\n"); err != nil {
		panic(err)
	}
	if err := clientTCP.CloseWrite(); err != nil {
		panic(err)
	}

	reply, err := io.ReadAll(clientTCP)
	if err != nil {
		panic(err)
	}
	fmt.Printf("client received application bytes: %q\n", reply)
	fmt.Println("client read EOF after the server closed its write side")
	if err := <-serverDone; err != nil {
		panic(err)
	}
}
07 / Carry the model forward

Use the lifecycle to choose the next measurement.

For the protocol state machine and close behavior, read RFC 9293, Transmission Control Protocol. The loopback APIs are documented in the Node.js node:net reference and the Go net package. Packet and socket tools show a local observation; the endpoints and full path may have additional state.