Here is the structure a programmer filled in on FreeBSD 4.1, released in July 2000:
struct kevent {
uintptr_t ident; /* identifier for this event */
short filter; /* filter for event */
u_short flags;
u_int fflags;
intptr_t data;
void *udata; /* opaque user data identifier */
};
And here is the same structure on FreeBSD 15, twenty-six years later:
struct kevent {
__uintptr_t ident; /* identifier for this event */
short filter; /* filter for event */
unsigned short flags; /* action flags for kqueue */
unsigned int fflags; /* filter flag value */
__int64_t data; /* filter data value */
void *udata; /* opaque user data identifier */
__uint64_t ext[4]; /* extensions */
};
Same six fields, same order, same meanings. One of them had its width pinned down, and four words of room were added at the end for things nobody had thought of yet. That is the whole of the change.
What grew in the meantime was everything else. The file that holds this definition went from 165 lines to 394. The number of things you can ask the kernel to watch for went from six to fourteen. And a programme written against the 2000 header compiles against the 2026 one, because nothing it relies on has moved.
Two calls, fourteen kinds of event
The mechanism, for anybody meeting it for the first time, is a queue you own and a single call you make against it.
kqueue() creates the queue and hands back a descriptor. kevent() does everything else: you pass it a list of changes, meaning things you now want watched or no longer want watched, and it returns a list of events that have happened. Each entry in either array is a kevent, the same structure serving both directions. Registration and retrieval are the same call, distinguished by which array you filled in. Two system calls in total, and the second one is the whole interface.
What you can register is the part that has aged well. The 2000 release shipped six filters: read, write, file changes, process changes, signals and asynchronous I/O. Fourteen ship today, and the nine that arrived since cover things that did not exist when the thing was designed. EVFILT_TIMER gives you a timer as an event rather than a separate mechanism. EVFILT_USER lets one thread wake another through the same queue it is already waiting on, which quietly retires the self-pipe trick and the small permanent faff that went with it. EVFILT_PROCDESC and EVFILT_JAIL and EVFILT_JAILDESC arrived with the descriptor-based process and jail work, years apart. EVFILT_SENDFILE notifies on the completion of a transfer that never entered userspace at all.
Not one of those required a new system call, a new structure, or a version negotiation. They are numbers in a field that was already there, and that field is sixteen bits wide, which leaves room for rather more ideas than anyone has had so far.
What it costs, and what it has cost
The subsystem in FreeBSD 15 is 3,495 lines of C in sys/kern/kern_event.c, of which 2,709 remain once comments and blank lines come out. The public header is 394 lines, 278 without comments. That is the entire thing: one file of implementation, one of definitions.
One number for scale, with no scoring intended: epoll on Linux sits in fs/eventpoll.c at 3,030 lines, 1,615 without comments, with a 101-line header. It is a smaller body of code, and it should be, because it watches one kind of thing. Comparing the two on line count alone would be a category error, and the honest way to read the numbers is that fourteen event sources cost FreeBSD roughly a thousand lines more than one event source costs Linux.
The security record deserves the same treatment, which means stating it rather than counting it. There have been kernel panics reachable from an unprivileged process: 4.3 through 4.6 could be brought down through a pipe with one end closed and a write filter registered on the other. A null pointer dereference in 6.1 was good for local privilege escalation. And the most recent is six weeks old at the time of writing: FreeBSD-SA-26:50, CVE-2026-58083, a use-after-free in the copy performed during fork, where a timer filter could fire and queue itself before the copy had finished. It affects FreeBSD 15.1.
Twenty-six years in the kernel, on the path of every network daemon on the system, and the list is not empty. Anybody who tells you a subsystem of this age and this reach has a clean record is either not looking or not telling.
Where it lives
It is in FreeBSD, which is where it started, and in NetBSD, OpenBSD and DragonFly. It is in macOS, and has been for long enough that a substantial part of the software running on the world's Macs waits on a queue designed for a BSD in 2000. Anything built on libuv, libevent or libev reaches it on those platforms without the author ever typing the name, which is the usual fate of an interface that works: it disappears under a wrapper and keeps running.
That spread matters for a reason beyond arithmetic. A structure defined once and adopted by five kernels cannot be quietly revised, because five projects would have to agree, and the cost of changing it therefore stays permanently higher than the cost of working with it. Nobody decided on stability of that kind. It accrued, because an interface small enough to be copied, and good enough to be worth copying, ends up preserved by the people who copied it.
The limit
The design has edges, and they are known.
The interface is not portable, and that is the plain fact of it. Software that wants to run on Linux as well needs epoll too, and in practice that means an abstraction layer, which means the elegance is enjoyed by the author of libevent rather than by the author of the application. A great deal of the world therefore benefits from this design without ever being able to see it.
The single-call design that makes registration cheap also makes error handling awkward, because one call can partially succeed: some changes applied, one rejected, events returned regardless, and the errors come back in the same array as the results. Code that gets this right is rarely the code anybody writes on the first attempt, and the mess it makes tends to surface in production, long after review signed it off.
And the age cuts both ways. Twenty-six years of stability means twenty-six years of decisions that cannot be revisited, including a few the original author would probably take back. EVFILT_PROC has never been entirely satisfactory for tracking process trees, and the descriptor-based work that eventually addressed it arrived as another filter instead of a fix, because fixing it would have meant moving something that does not move.
The sheet
Efficiency. 2,709 lines of implementation without comments, 278 lines of header, fourteen event filters, two system calls. One call performs both registration and retrieval.
Security. Not a clean record: local panics in 4.3 to 4.6, a local privilege escalation in 6.1, and a use-after-free during fork disclosed on 29 July 2026 as CVE-2026-58083, affecting 15.1. Runs in the kernel, on the path of every daemon that waits for anything.
Longevity. Introduced in FreeBSD 4.1 in July 2000 by Jonathan Lemon. Present in FreeBSD, NetBSD, OpenBSD, DragonFly and macOS. Continuously maintained, with new filters added across all twenty-six years.
Stability. Six fields defined in 2000 sit in the same order today, with one width pinned and four words appended. Nine filters added without a new system call, a new structure, or a version flag. A header from 2000 still describes the interface you get in 2026.
The thing to take from those numbers is not that nothing happened for twenty-six years. A great deal happened: timers, user events, process descriptors, jails, sendfile completion, and a rewrite of the internals somewhere in the middle. It happened in the space that was left for it, which is the part that had to be designed correctly the first time, and which cost six fields and a spare.