Windows Sockets (Winsock2) Programming in C++ — A Modern Tutorial

Forget WSAStartup and every call fails with 10093. A modern Winsock2 tutorial: dual-stack TCP, RAII, and the six real Windows differences.

Two terminals connected by a line passing through an open gate, representing a Winsock TCP connection that cannot be established until WSAStartup initialises the sockets library

Every Winsock program starts with a call that has no equivalent on Linux or macOS, and forgetting it is the classic first bug: WSAStartup. Skip it and every subsequent socket call fails with error 10093, which says nothing useful about what you actually did wrong.

That call is one of about six real differences between Winsock and the Berkeley sockets API the rest of the world uses. This guide covers the modern way to write Winsock2 code in C++: a TCP server and client using getaddrinfo rather than the deprecated gethostbyname, a single dual-stack socket that accepts IPv4 and IPv6, RAII wrappers so sockets and the Winsock session close themselves, readable error handling, and a compatibility header that lets the same source build on Windows and POSIX.

How this was tested. Every program below was compiled with MSVC 19.43.34810 (cl /EHsc /std:c++17 /W4 ... ws2_32.lib) for both x86 and x64, and run on Windows 11, build 10.0.26200 — zero warnings, zero errors on either target. The client and server were exercised end to end over IPv4 and IPv6 loopback, and the failure paths were triggered deliberately rather than described. Every output block below is captured verbatim from those runs, and a section at the end sets out the full test matrix.

Table of Contents

The Short Answer

How do I start with Winsock? Call WSAStartup(MAKEWORD(2,2), &data) before anything else, link ws2_32, and call WSACleanup on the way out.

How do I link it? MSVC: cl /EHsc /std:c++17 server.cpp ws2_32.lib. MinGW: add -lws2_32.

How different is it from Linux sockets? Less than you’d think. Six differences carry almost all of it: the startup/cleanup pair, SOCKET instead of int, closesocket instead of close, WSAGetLastError instead of errno, char* instead of void* in setsockopt, and different header names.

Winsock and Berkeley Sockets: The Real Differences

Winsock was deliberately modelled on Berkeley sockets, so socket, bind, listen, accept, connect, send and recv all mean what they mean everywhere else. What differs is the surrounding scaffolding:

ConcernWinsock (Windows)Berkeley (Linux, macOS)
Library initWSAStartup / WSACleanup requirednone
Socket typeSOCKET, an unsigned handleint, a file descriptor
Invalid valueINVALID_SOCKET-1
Error returnSOCKET_ERROR-1
Closingclosesocket()close()
Error codeWSAGetLastError()errno
setsockopt valueconst char*const void*
Headers<winsock2.h>, <ws2tcpip.h><sys/socket.h>, <netdb.h>, …
Linkingws2_32usually nothing

The consequential one is that a SOCKET is not a file descriptor. You cannot pass it to read, write or close, and on Windows those names refer to unrelated CRT functions that will accept the value and do the wrong thing.

Setting Up: WSAStartup and RAII

WSAStartup initialises the Winsock DLL and negotiates a version. Because it needs a matching WSACleanup, it is a textbook case for a small RAII type:

class WsaSession {
public:
    WsaSession() {
        WSADATA data{};
        int rc = WSAStartup(MAKEWORD(2, 2), &data);
        if (rc != 0) throw std::runtime_error("WSAStartup failed: " + std::to_string(rc));
    }
    ~WsaSession() { WSACleanup(); }
    WsaSession(const WsaSession&) = delete;
    WsaSession& operator=(const WsaSession&) = delete;
};

Two details. WSAStartup returns the error code directly — it does not set it for WSAGetLastError, because Winsock is not initialised yet. And the version is passed as MAKEWORD(2,2), requesting Winsock 2.2 — the version modern Windows applications normally ask for. There is no reason to request anything lower.

A socket deserves the same treatment. A raw SOCKET leaks on every early return, and network code is full of early returns:

class Socket {
    SOCKET s_ = INVALID_SOCKET;
public:
    Socket() = default;
    explicit Socket(SOCKET s) : s_(s) {}
    ~Socket() { reset(); }
    Socket(Socket&& o) noexcept : s_(o.s_) { o.s_ = INVALID_SOCKET; }
    Socket& operator=(Socket&& o) noexcept {
        if (this != &o) { reset(); s_ = o.s_; o.s_ = INVALID_SOCKET; }
        return *this;
    }
    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;

    void reset() { if (s_ != INVALID_SOCKET) { closesocket(s_); s_ = INVALID_SOCKET; } }
    SOCKET get() const { return s_; }
    bool valid() const { return s_ != INVALID_SOCKET; }
};

Movable, not copyable — copying a socket handle and destroying both copies would close it twice.

A Dual-Stack TCP Server

The important choice here is at the top: AF_INET6 plus IPV6_V6ONLY cleared. That gives one socket that accepts both IPv6 and IPv4 clients, instead of the two-listener arrangement older tutorials use.

addrinfo hints{};
hints.ai_family   = AF_INET6;      // IPv6 socket...
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
hints.ai_flags    = AI_PASSIVE;    // ...bound to the wildcard address

addrinfo* raw = nullptr;
int rc = getaddrinfo(nullptr, "5555", &hints, &raw);
if (rc != 0) { std::cerr << "getaddrinfo: " << wsaError(rc) << '\n'; return 1; }
std::unique_ptr<addrinfo, decltype(&freeaddrinfo)> info(raw, &freeaddrinfo);

Socket listener(socket(info->ai_family, info->ai_socktype, info->ai_protocol));
if (!listener.valid()) { std::cerr << "socket: " << wsaError(WSAGetLastError()) << '\n'; return 1; }

// Dual-stack: clearing IPV6_V6ONLY lets one IPv6 socket accept IPv4 too.
DWORD v6only = 0;
if (setsockopt(listener.get(), IPPROTO_IPV6, IPV6_V6ONLY,
               reinterpret_cast<const char*>(&v6only), sizeof v6only) == SOCKET_ERROR) {
    std::cerr << "IPV6_V6ONLY: " << wsaError(WSAGetLastError()) << '\n';
    return 1;
}

if (bind(listener.get(), info->ai_addr, static_cast<int>(info->ai_addrlen)) == SOCKET_ERROR) { /* ... */ }
if (listen(listener.get(), SOMAXCONN) == SOCKET_ERROR) { /* ... */ }

Note freeaddrinfo wrapped in a unique_ptr with a custom deleter — getaddrinfo allocates a linked list that must be released, and an early return in between would otherwise leak it.

The accept loop reads and echoes. The detail worth copying is the send loop:

int n = recv(conn.get(), buf, sizeof buf, 0);
if (n > 0) {
    // send() may send fewer bytes than asked: loop until done.
    int sent = 0;
    while (sent < n) {
        int k = send(conn.get(), buf + sent, n - sent, 0);
        if (k == SOCKET_ERROR) { /* handle */ break; }
        sent += k;
    }
} else if (n == 0) {
    // The peer performed an orderly shutdown.
} else {
    // SOCKET_ERROR: call WSAGetLastError().
}

send is not obliged to send everything you give it, and recv is not obliged to return a whole message. TCP is a byte stream with no message boundaries — if you need them preserved, that is what UDP datagrams give you, as in our UDP sender and receiver. Treating a single recv as one message is a very common beginner bug in socket code, second only to forgetting WSAStartup.

A Client That Works With IPv4 and IPv6

The client sets AF_UNSPEC and walks whatever the resolver returns, trying each until one connects. No branching on address family, and no gethostbyname:

addrinfo hints{};
hints.ai_family   = AF_UNSPEC;     // let the resolver return IPv4 and IPv6
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
// ... getaddrinfo(host, port, &hints, &raw) ...

Socket sock;
for (addrinfo* a = info.get(); a != nullptr; a = a->ai_next) {
    Socket candidate(socket(a->ai_family, a->ai_socktype, a->ai_protocol));
    if (!candidate.valid()) continue;
    if (connect(candidate.get(), a->ai_addr, static_cast<int>(a->ai_addrlen)) != SOCKET_ERROR) {
        sock = std::move(candidate);
        break;                     // first address that connects wins
    }
}

getaddrinfo replaces gethostbyname, inet_addr and inet_ntoa in one go. All three are deprecated on Windows and IPv4-only; MSVC will warn about them unless you define _WINSOCK_DEPRECATED_NO_WARNINGS, which is a signal to modernise rather than a switch to flip. getnameinfo is the matching function for turning an address back into text.

After sending, the client calls shutdown(sock.get(), SD_SEND). That is what makes the server’s recv return 0 rather than blocking forever — a half-close saying “I have no more to send, but I am still listening”.

The order the calls have to happen in Amber calls are Windows-only. Everything else has a Berkeley sockets twin. SERVER CLIENT WSAStartup() socket() setsockopt(IPV6_V6ONLY,0) bind() listen() accept() blocks recv() / send() closesocket() WSACleanup() WSAStartup() getaddrinfo() socket() connect() send() / recv() shutdown(SD_SEND) closesocket() connect() is what unblocks accept() shutdown() makes the server’s recv() return 0 Forgetting WSAStartup is the classic first bug: every later call fails with 10093.

Verifying It Works

Built with MSVC and run on Windows 11. Server:

C:\...\sockets>server.exe
socket family : AF_INET6
IPV6_V6ONLY   : 0  (0 = dual-stack enabled)
listening on port 5555
connection from [::ffff:127.0.0.1]:62973
peer closed
connection from [::1]:62974
peer closed

Client, connecting twice to that one server — once over IPv4, once over IPv6:

C:\...\sockets>client.exe 127.0.0.1 5555
hello from the client
server closed

C:\...\sockets>client.exe ::1 5555
hello from the client
server closed

The first connection from line is the proof. ::ffff:127.0.0.1 is an IPv4-mapped IPv6 address: a client that connected over IPv4 arriving on a socket created as AF_INET6. One listener, both families, no second socket and no branching. The IPV6_V6ONLY : 0 line above it is read back with getsockopt after being set, so even that is observed rather than assumed.

Compilation produced no warnings under /W4, MSVC’s near-equivalent of -Wall -Wextra, on both x86 and x64 targets.

Error Handling and Troubleshooting

Winsock error codes are integers, and integers in a log are not much help. FormatMessageA turns one into the system’s own description:

std::string wsaError(int code) {
    char* text = nullptr;
    DWORD n = FormatMessageA(
        FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM |
        FORMAT_MESSAGE_IGNORE_INSERTS,
        nullptr, static_cast<DWORD>(code),
        MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT),
        reinterpret_cast<char*>(&text), 0, nullptr);
    std::string msg = (n && text) ? std::string(text, n) : "unknown error";
    if (text) LocalFree(text);
    while (!msg.empty() && (msg.back() == '\n' || msg.back() == '\r')) msg.pop_back();
    return std::to_string(code) + " (" + msg + ")";
}

FORMAT_MESSAGE_ALLOCATE_BUFFER means Windows allocates the string and you must LocalFree it. The trailing newline strip is there because the system messages end with CRLF.

The codes you will actually meet — the full list runs to over a hundred. The “Windows says” column is the literal text FormatMessageA returned on the test machine, and every constant was checked against the SDK headers with a static_assert:

CodeNameWindows saysWhat it usually means
10093WSANOTINITIALISEDEither the application has not called WSAStartup, or WSAStartup failed.You forgot WSAStartup, or called it after WSACleanup
10013WSAEACCESAn attempt was made to access a socket in a way forbidden by its access permissions.A privileged port, or a firewall rule
10035WSAEWOULDBLOCKA non-blocking socket operation could not be completed immediately.Nothing to do yet. Not an error
10047WSAEAFNOSUPPORTAn address incompatible with the requested protocol was used.Address family unavailable — often IPv6 disabled
10048WSAEADDRINUSEOnly one usage of each socket address (protocol/network address/port) is normally permitted.Port already bound — often your own last run
10054WSAECONNRESETAn existing connection was forcibly closed by the remote host.Peer crashed or exited hard
10060WSAETIMEDOUTA connection attempt failed because the connected party did not properly respond after a period of time…Commonly a firewall dropping packets
10061WSAECONNREFUSEDNo connection could be made because the target machine actively refused it.Nothing is listening on that port

Starting a second copy of the server while the first still holds the port produces exactly that, through the wsaError helper above:

--- console 1 ---
C:\...\sockets>server.exe
socket family : AF_INET6
IPV6_V6ONLY   : 0  (0 = dual-stack enabled)
listening on port 5555

--- console 2 ---
C:\...\sockets>server.exe
bind: 10048 (Only one usage of each socket address (protocol/network address/port) is normally permitted.)

The error you read may not be the error that happened

This one was found by running the client with nothing listening. Instead of 10061 it printed:

could not connect to 127.0.0.1:5555 - 0 (The operation completed successfully.)

The last error is per-thread state, and you cannot assume it survives another API call. Microsoft’s own guidance is to read it immediately, because some functions set it to zero when they succeed. The client tries each address getaddrinfo returned; when connect fails it moves on, and the failed candidate’s destructor calls closesocket. Something between the failed connect and the read cleared the value — by the time the loop ended and the code asked WSAGetLastError(), the real reason was gone.

The fix is to read the error at the moment it happens:

int lastConnectError = 0;              // captured before anything can clear it
for (addrinfo* a = info.get(); a != nullptr; a = a->ai_next) {
    Socket candidate(socket(a->ai_family, a->ai_socktype, a->ai_protocol));
    if (!candidate.valid()) { lastConnectError = WSAGetLastError(); continue; }
    if (connect(candidate.get(), a->ai_addr, static_cast<int>(a->ai_addrlen)) != SOCKET_ERROR) {
        sock = std::move(candidate);
        break;
    }
    // Read it NOW. Destroying `candidate` calls closesocket(), and a
    // successful Winsock call resets the thread's last error to 0.
    lastConnectError = WSAGetLastError();
}

With that change, the same test reports the real reason:

C:\...\sockets>client.exe 127.0.0.1 5555
could not connect to 127.0.0.1:5555 - 10061 (No connection could be made because the target machine actively refused it.)

Call WSAGetLastError() on the line after the call that failed, before any cleanup, logging or destructor can run. This bites hardest in exactly the code that looks most careful — a RAII socket wrapper makes the clobbering invisible, because the call that destroys the evidence is one you never wrote.

The same discipline applies on POSIX — errno is only meaningful immediately after a call that reports failure — but the failure mode is less visible there. On the machine used for this article the identical loop written against Berkeley sockets kept its errno through the close() and reported the right error, which is luck rather than a guarantee.

Two more that mislead people. 10035 is not a failure — on a non-blocking socket it means “try again later”, and treating it as fatal breaks otherwise correct code. And 10048 is usually simpler than it looks: another socket already owns that address and port, often a previous run of your own program that is still alive. Recently closed connections can also sit in TIME_WAIT and affect rebinding, but that is a connection state — a listening socket does not enter it. Check with netstat -ano before reaching for socket options. Note too that SO_REUSEADDR means something different on Windows than on Linux: it can let another process bind over your listener, which is why Windows also offers SO_EXCLUSIVEADDRUSE.

One Codebase for Windows and POSIX

The differences are small enough to hide behind a header, which is worth doing before your project needs it rather than after:

#pragma once
#ifdef _WIN32
  #include <winsock2.h>
  #include <ws2tcpip.h>
  #pragma comment(lib, "ws2_32.lib")            // MSVC links ws2_32 automatically
  using socket_t = SOCKET;
  constexpr socket_t kBadSocket = INVALID_SOCKET;
  inline int  closeSocket(socket_t s) { return closesocket(s); }
  inline int  lastError()             { return WSAGetLastError(); }
  constexpr int kShutSend = SD_SEND;
  using sockopt_t = char;                        // Windows takes char*
#else
  #include <arpa/inet.h>
  #include <netdb.h>
  #include <netinet/in.h>
  #include <sys/socket.h>
  #include <unistd.h>
  #include <cerrno>
  using socket_t = int;                          // POSIX: a plain file descriptor
  constexpr socket_t kBadSocket = -1;
  inline int  closeSocket(socket_t s) { return ::close(s); }
  inline int  lastError()             { return errno; }
  constexpr int kShutSend = SHUT_WR;
  using sockopt_t = void;                        // POSIX takes void*
  constexpr int SOCKET_ERROR = -1;
#endif

WSAStartup still needs an #ifdef at the top of main, because there is nothing to map it onto. Everything else — getaddrinfo, bind, listen, accept, send, recv, getnameinfo — is identical on both sides.

This header is not hypothetical: the server and client in this article were built both ways from one source, with MinGW for Windows and GCC for Linux.

Scaling Beyond One Connection

The server above handles one client at a time. Three ways forward, in rough order of how much they cost you:

  • A thread per connection. Simplest, and often adequate for modest connection counts. Each thread costs a stack and a scheduling slot, so the ceiling depends on your workload rather than on a fixed number.
  • select or WSAPoll. One thread watching many sockets. On Windows an fd_set holds at most FD_SETSIZE sockets — 64 by default, changeable by defining it before including the Winsock headers. That is a limit on how many sockets you can watch, not on their numeric handle values, which is where the POSIX version differs. WSAPoll is the closer analogue to POSIX poll.
  • I/O completion ports (IOCP). The Windows-native answer for high connection counts: you post overlapped operations and collect completions from a pool of worker threads. It is the mechanism behind most high-performance Windows servers, and it has no direct POSIX equivalent — epoll and kqueue solve the same problem with a readiness model rather than a completion model.

No measurement is offered here for any of these, because none was run. The ordering above reflects how they are commonly described in the platform documentation, not a benchmark on this machine.

Basic Security Practices

Networking code is attacker-facing by definition:

  • Never trust a length that arrived over the wire. Validate it against your buffer before using it, and remember that recv fills a buffer, not a string — it does not null-terminate.
  • Bind to a specific interface when the service is not meant to be public, rather than the wildcard address.
  • Set timeouts with SO_RCVTIMEO and SO_SNDTIMEO. In a blocking, thread-per-connection design like the one above, a peer that connects and never sends will otherwise occupy a thread indefinitely. Event-driven and overlapped designs need the equivalent guard, just enforced differently.
  • Use TLS for anything crossing a network you do not control. Winsock gives you a byte stream and no confidentiality; Schannel is the Windows-native TLS provider.
  • Cap concurrent connections. An unbounded accept loop is a denial-of-service waiting for a script.
  • Raw sockets need elevation. SOCK_RAW requires administrator rights on Windows, which is why tools built on it — such as our ICMP ping implementation — must be run elevated.

How This Was Tested

Every claim in this article that could be tested was tested. Here is the matrix, including the one thing that was not.

ClaimVerified?How
Compiles and links under MSVCYescl /EHsc /std:c++17 /W4 ... ws2_32.lib, MSVC 19.43.34810 — zero warnings on both x86 and x64 from the same source
Runs on WindowsYesWindows 11, build 10.0.26200; all output above captured verbatim
Dual-stack: one AF_INET6 socket serving both familiesYesgetsockopt read IPV6_V6ONLY = 0 back after setting it, and an IPv4 client arrived as ::ffff:127.0.0.1 on that socket
The error-code valuesYesEight static_asserts compiled against Microsoft’s own SDK headers; a deliberately wrong value fails to compile
The FormatMessageA textYesCaptured from a run — it is the “Windows says” column above, identical on x86 and x64
WSAEADDRINUSE (10048)YesTriggered by starting a second server; both consoles captured
WSAECONNREFUSED (10061)YesTriggered by running the client with nothing listening
The last-error clobbering bug, and its fixYesThe same test reported 0 before the fix and 10061 after
The same source building for POSIXYesCompatibility header built with GCC 13.3 on Ubuntu 24.04 and run over IPv4 loopback
select versus WSAPoll versus IOCP performanceNoNot implemented and not measured. The ordering given above comes from the platform documentation, not from a benchmark on this machine

One scope note: the POSIX half was built and run on Linux, not on macOS or BSD.

Key Takeaways

  • WSAStartup first, always. Skip it and everything else fails with 10093, an error that does not name the actual mistake.
  • Wrap the session and the socket in RAII types. Network code is full of early returns, and every one of them is a leak otherwise.
  • Use getaddrinfo, not gethostbyname. One function, IPv6-aware, and it replaces inet_addr and inet_ntoa too.
  • One IPv6 socket with IPV6_V6ONLY cleared serves both families — no second listener.
  • send may send less than you asked, and recv does not preserve message boundaries. Always loop on send; loop on recv when your protocol needs a whole message assembled.
  • WSAEWOULDBLOCK (10035) is not an error on a non-blocking socket, and WSAEADDRINUSE (10048) is often your own last run in TIME_WAIT.
  • Read WSAGetLastError() immediately after the call that failed. A successful Winsock call — including the closesocket in a destructor — resets it to 0. Running the client with nothing listening reported 0 (The operation completed successfully.) until this was fixed.
  • The Windows/POSIX gap is small enough to hide behind one header — the server and client here were built both ways from one source.

Frequently Asked Questions

Conclusion

The version of this article that stood here for years listed nine function signatures and no working program, which is a reasonable description of Winsock in 2007 and a poor one now. What has actually changed is not the function list — socket, bind, listen, accept are the same calls — but everything around it: address resolution went IPv6-aware, RAII made handle leaks avoidable, and the gap between Windows and POSIX narrowed to a handful of typedefs.

That last point is the one worth carrying away. Winsock is not a separate discipline from sockets programming; it is Berkeley sockets with six differences and a mandatory startup call. Write it behind a small compatibility header and most of your networking code stops caring which platform it is on. Our TCP client and server shows the same API applied to a different shape of problem.

Scroll to Top