A customer wrote in with a simple observation that turned out to be exactly right: serving files from the HTTP server used a lot of memory. If TsgcWebSocketHTTPServer served a large file from DocumentRoot, the whole file was read into RAM for every connection. One hundred clients downloading the same 1 GB file meant roughly 100 GB of RAM. This release fixes that. Files now stream from disk and server memory stays flat regardless of file size and how many clients are connected. It ships in sgcWebSockets 2026.6.0.
The problem: one full copy per connection
Both static-file paths, DoResponseHTTP for HTTP/1.x and DoHTTP2DocumentRoot for HTTP/2, built the response the same way. They created a TMemoryStream, called LoadFromFile, and handed that stream to the response. LoadFromFile reads the entire file into memory in one go, so the cost per request was the full file size, and every concurrent download held its own copy.
// Old behaviour, simplified
oStream := TMemoryStream.Create;
oStream.LoadFromFile(vDocument); // entire file into RAM
AResponseInfo.ContentStream := oStream;
That makes peak memory roughly file size × concurrent connections. A handful of clients pulling a video, an installer, a disk image or a database backup is enough to push a server into swapping or an out-of-memory crash.
The slow-read multiplier
The same customer described a second scenario, and it is a real attack: many connections that each read the response very slowly, on the order of one byte per second. This is a slow-read attack, a variant of Slowloris. A client that drips its reads keeps the connection open for a very long time, and with the old code each of those connections also pinned a full in-memory copy of the file. A few thousand slow readers of a modest file could hold gigabytes hostage while transferring almost nothing.
How we measured it
We built a small harness: a server with a DocumentRoot and a big test file, plus a client with two modes. A "fast" mode opens N concurrent normal downloads, and a "slow" mode opens M raw connections that send a valid request and then read the body at one byte per second. We sampled the server's working set with a 100 MB file, before the fix and after.
| Scenario (100 MB file) | Before fix | After fix |
|---|---|---|
| 5 concurrent downloads | ~511 MB | ~11 MB |
| 10 concurrent downloads | ~1,012 MB | ~11 MB |
| 20 concurrent downloads | ~1.7 GB | ~12 MB |
| 100 slow-read (1 byte/sec) connections | ~1.67 GB held flat | ~19 MB |
| File integrity | n/a | SHA-256 byte-exact, correct Content-Length |
Before the fix, memory tracked the file size times the connection count almost exactly. After the fix it stays in the tens of megabytes no matter how large the file is or how many clients connect.
The fix: stream from disk
The file is now served with a read-only, shared TFileStream instead of a TMemoryStream. Nothing else in the pipeline had to change, because the write side already streams: the HTTP/1.x server sends the content stream in 32 KB chunks, and the HTTP/2 server emits it in roughly 64 KB DATA frames. So the server reads a small chunk from disk, sends it, and moves on, which drops the memory cost per connection from the whole file to a few tens of kilobytes.
// New behaviour, simplified
oStream := TFileStream.Create(vDocument, fmOpenRead or fmShareDenyWrite);
oStream.Position := 0;
AResponseInfo.ContentStream := oStream;
Content-Length is still correct, because it comes from TFileStream.Size, and the stream is freed after the response exactly as before. We verified that downloads are byte-identical to the source file with a SHA-256 comparison, and that the header reports the right length. There is no API change and nothing to configure: setting DocumentRoot works the way it always did, it just no longer buffers the file.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.Port := 80;
oServer.DocumentRoot := 'C:\inetpub\files'; // large files now stream from disk
oServer.Active := True;
The .NET edition inherits the fix automatically, because its DocumentRoot file serving runs in the same compiled core that the Delphi components use.
Slowing down slow readers
Streaming from disk removes the memory amplification, so a slow reader can no longer pin a whole file in RAM. What is left is the connection itself, and for that there is a send timeout. Options.WriteTimeOut sets a deadline on socket writes, so a client that stops accepting data is dropped instead of blocking a server thread forever. In this release that timeout works on Linux and other POSIX platforms too, not only on Windows. The POSIX socket API expects a timeval rather than a millisecond integer, and that is now handled correctly.
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.DocumentRoot := 'C:\inetpub\files';
oServer.Options.WriteTimeOut := 15000; // drop a client that stalls for 15s, Windows and POSIX
oServer.Active := True;
For a public server we recommend combining three levers: set Options.WriteTimeOut so stalled writes are dropped, use the firewall's RateLimit.MaxConnectionsPerIP to cap how many connections a single address can open, and consider a thread-pool scheduler or the IOCP and EPOLL handlers so a flood of slow connections cannot exhaust one thread each.
Upgrading
The streaming fix is a drop-in. As soon as you update, DocumentRoot serves files from disk with flat memory, with no code change. The change was verified across Delphi 7 through 13, on the HTTP/1.x and HTTP/2 paths, and the .NET build picks it up through the shared core. If you serve large static files, this is a meaningful reduction in memory and a useful step against slow-read abuse.
Update from the sgcWebSockets download page, or grab it through GetIt or your registered account.
Questions, feedback or migration help? Get in touch, you will get a reply from the people who wrote the code.
