diff --git a/CMakeLists.txt b/CMakeLists.txt
index c2f8fcc97661f7c1681e9d9ddfdffb7e99bd70d7..838c40d7274133d762489896a34b7ae5e3c9a02b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -6,9 +6,10 @@ set(CXX_STANDARD_REQUIRED ON)
 
 include(CPM.cmake)
 CPMAddPackage("gh:OlivierLDff/asio.cmake@1.1.3")
+CPMAddPackage("gh:fmtlib/fmt#9.1.0")
 
 add_executable(sender sender.cpp)
-target_link_libraries(sender PRIVATE asio::asio)
+target_link_libraries(sender PRIVATE asio::asio fmt::fmt)
 
 add_executable(receiver receiver.cpp)
 target_link_libraries(receiver PRIVATE asio::asio)
diff --git a/packet.hpp b/packet.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..65af808fc3a458b031e5851ee5e3fe711113a201
--- /dev/null
+++ b/packet.hpp
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <array>
+#include <cstddef>
+#include <cstdint>
+
+constexpr std::size_t MAX_PACKET_SIZE = 65507;
+
+struct Packet {
+    std::uint64_t id;
+    std::array<std::uint8_t, MAX_PACKET_SIZE - 8> payload;
+};
+
+static_assert(sizeof(Packet) >= MAX_PACKET_SIZE);
diff --git a/receiver.cpp b/receiver.cpp
index 68649b480e391f6656e71c259fc50ae57d3967c2..cb29c6a1f5e20a1a049a3006a4ceff5a3d200b5a 100644
--- a/receiver.cpp
+++ b/receiver.cpp
@@ -1,3 +1,4 @@
+#include "packet.hpp"
 #include <asio.hpp>
 #include <asio/buffer.hpp>
 #include <cstddef>
@@ -8,6 +9,11 @@
 using udp = asio::ip::udp;
 using clk = std::chrono::steady_clock;
 
+struct ReceivedContext {
+  std::uint64_t highest_id_received;
+  clk::time_point first_receive;
+};
+
 class Receiver {
 public:
   Receiver(std::uint16_t port)
@@ -20,34 +26,59 @@ public:
 private:
   asio::io_context context;
   udp::socket socket;
-  std::array<std::byte, 1024> data;
-  std::optional<clk::time_point> first_receive;
-  std::uint64_t bytes_received = 0;
+  Packet data;
+  std::optional<ReceivedContext> receive_data;
+  std::uint64_t total_bytes_received = 0;
+  std::uint64_t packets_received = 0;
+  std::uint64_t out_of_order_delivered_packets = 0;
   clk::time_point last_print;
 
   void receive() {
-    socket.async_receive(asio::buffer(data), [this](std::error_code ec,
-                                                    std::size_t bytes_recvd) {
-      if (ec) {
-        std::cerr << ec.category().name() << ": " << ec.message() << std::endl;
-      } else {
-        bytes_received += bytes_recvd;
-        if (!first_receive.has_value()) {
-          first_receive = last_print = clk::now();
-        } else {
-          const auto dt = clk::now() - *first_receive;
-          const auto seconds =
-              std::chrono::duration_cast<std::chrono::duration<double>>(dt)
-                  .count();
-
-          if (clk::now() - last_print > std::chrono::seconds(2)) {
-            std::cout << bytes_received * 8 / seconds / 1000.0 / 1000.0 << " Mbit/s" << std::endl;
-            last_print = clk::now();
+    socket.async_receive(
+        asio::buffer(&data, MAX_PACKET_SIZE),
+        [this](std::error_code ec, std::size_t bytes_received) {
+          if (ec) {
+            std::cerr << ec.category().name() << ": " << ec.message()
+                      << std::endl;
+          } else {
+            if (!receive_data.has_value()) {
+              // ignore first packet
+              receive_data.emplace(ReceivedContext{
+                  .highest_id_received = data.id,
+                  .first_receive = clk::now(),
+              });
+            } else {
+              packets_received += 1;
+              total_bytes_received += bytes_received;
+
+              const auto dt = clk::now() - receive_data->first_receive;
+              const auto seconds =
+                  std::chrono::duration_cast<std::chrono::duration<double>>(dt)
+                      .count();
+              // std::cout << "ID: " << data.id << " length: " << bytes_received
+              //           << std::endl;
+              //
+              if (data.id > receive_data->highest_id_received) {
+                if (data.id != receive_data->highest_id_received + 1) {
+                  out_of_order_delivered_packets += 1;
+                }
+                receive_data->highest_id_received = data.id;
+              }
+
+              if (clk::now() - last_print > std::chrono::seconds(2)) {
+                std::cout << total_bytes_received * 8 / seconds / 1000.0 /
+                                 1000.0
+                          << " Mbit/s"
+                          << ", " << (data.id - packets_received)
+                          << " packets lost"
+                          << ", " << out_of_order_delivered_packets
+                          << " packets delivered out-of-order" << std::endl;
+                last_print = clk::now();
+              }
+            }
+            this->receive();
           }
-        }
-        this->receive();
-      }
-    });
+        });
   }
 };
 
diff --git a/sender.cpp b/sender.cpp
index 49c082cf7989b97de4083a76fd3692253e164129..c891c29703055575b995b2db03c06f300a159f3e 100644
--- a/sender.cpp
+++ b/sender.cpp
@@ -1,9 +1,13 @@
+#include "packet.hpp"
 #include <asio.hpp>
 #include <asio/buffer.hpp>
 #include <chrono>
 #include <cstddef>
 #include <cstdint>
 #include <cstdio>
+#include <fmt/format.h>
+#include <fmt/os.h>
+#include <fstream>
 #include <iostream>
 
 using udp = asio::ip::udp;
@@ -16,29 +20,47 @@ int main(int argc, char *argv[]) {
   udp::endpoint endpoint =
       *resolver.resolve(udp::v4(), argv[1], argv[2]).begin();
   udp::socket s(context, udp::endpoint(udp::v4(), 0));
-  // udp::socket s(context, endpoint);
 
-  int packet_size = argc >= 4 ? std::stoi(argv[3]) : 1024;
+  int packet_size = argc >= 4 ? std::stoi(argv[3]) : MAX_PACKET_SIZE;
   double time_between_packets = argc >= 5 ? std::stod(argv[4]) : 100;
 
-  std::cout << "packet size: " << packet_size << " byte" << std::endl;
+  std::cout << "packet size: " << packet_size << " bytes" << std::endl;
   std::cout << "send interval: " << time_between_packets << " ms" << std::endl;
 
+  if (packet_size < 8) {
+    std::cerr << "packet size must be at least 8 bytes and at most "
+              << MAX_PACKET_SIZE << " bytes" << std::endl;
+    return -1;
+  }
+
+  std::uint64_t bytes_sent = 0;
+  auto send_duration_file = fmt::output_file(fmt::format("send_durations_{}_{}.csv", packet_size, time_between_packets));
+
+  std::cout << "theoretical bandwidth: "
+            << packet_size * 1000 * 8 / time_between_packets / 1000.0 / 1000.0
+            << " Mbit/s" << std::endl;
+
   const auto dt = std::chrono::duration_cast<clk::duration>(
       std::chrono::duration<double, std::milli>(time_between_packets));
 
-  std::array<std::byte, 1024> data;
-
+  std::uint64_t skipped = 0;
+  Packet packet{.id = 0};
   auto last = clk::now();
   while (true) {
-    auto now = clk::now();
+    const auto now = clk::now();
 
     if (now - last > dt) {
-      s.send_to(asio::buffer(data, packet_size), endpoint);
-      last += dt;
-      if (clk::now() - now > dt) {
+      s.send_to(asio::buffer(&packet, packet_size), endpoint);
+      packet.id += 1;
+      bytes_sent += packet_size;
+      const auto after_send = clk::now();
+
+      send_duration_file.print("{},{}\n", std::chrono::duration_cast<std::chrono::duration<double, std::milli>>(after_send - now).count(), bytes_sent);
+      send_duration_file.flush();
+
+      while (last + dt < clk::now()) {
+        skipped++;
         std::putchar('.');
-        std::cout.flush();
         last += dt;
       }
     }