Serving a file the obvious way moves the data more times than you’d think:
read(): disk -> kernel page cache -> your buffer (copy 1, + context switch)
write(): your buffer -> kernel socket buffer -> NIC (copy 2, + context switch)
Two copies and two crossings of the user/kernel boundary, for bytes you never even looked at.
sendfile
If you’re just shoveling a file onto a socket, sendfile() keeps it in the kernel:
sendfile(out_sock, in_fd, &offset, count);
The page cache pages go to the socket without a detour through userspace. On modern NICs with scatter-gather DMA it can be genuinely zero-copy — the card reads straight from the page cache.
splice
splice() generalises it: move data between two file descriptors through a kernel pipe, again without a userspace copy. It’s how you build a fast proxy that doesn’t want to inspect the payload — splice from the inbound socket to the outbound one.
When not to bother
The moment you need to transform the bytes — compress, encrypt at the application layer, parse — you have to bring them into userspace anyway and zero-copy is moot. This is exactly why TLS termination is more expensive than plain file serving: you can’t sendfile something you have to encrypt in userspace. Knowing that shapes where you put the TLS boundary.