From magazine topic to project implementation
Relevant service and technical pages for this post
Why a Delphi WebSocket client is more than „Connect“ in practice
A Delphi WebSocket client can be assembled in minutes: URL, Connect, SendText, done. In custom enterprise software and process-near solutions the issue usually only becomes critical in operation: the reverse proxy severs idle connections, mobile or VPN paths have short NAT timeouts, certificates change, and on shutdown the process hangs because a receive loop is still blocked. In addition: a WebSocket is a long-lived, stateful channel — different rules apply than for classic HTTP/REST (Request/Response, short-lived).
In this source snippet it is not about „Hello WebSocket“, but about a production-ready client wrapper with:
- clean start/stop (without hangs on shutdown),
- Receive-Loop with Cancellation (cancellation signal) instead of ‚thread kill‘,
- reconnect with Backoff (controlled reconnection),
- heartbeat as an application pattern (because Ping/Pong is not available everywhere),
- debug and trace hooks that actually help in support cases.
The implementation is based on System.Net.WebSockets (Delphi RTL; WebSocket client API with TClientWebSocket). Where this RTL layer is not available in older versions or is too limited, a fallback via a library (e.g. ICS) is often sensible — a note on that follows below.
Architecture sketch: a wrapper instead of scattered WebSocket calls
A common mistake in mature Delphi applications: UI forms or service modules „talk directly to WebSocket“ and then have timers, threads and exception handling scattered everywhere. Better is a clear component with well-defined events and a small state machine.
Terms briefly explained: Backoff means a wait time that increases stepwise after errors (e.g. 1s, 2s, 4s …), to avoid flooding the server and network. CancellationToken is a cancellation signal from the .NET world; in Delphi there is no identical pattern, but we can emulate it with TEvent and a „StopRequested“ flag. TThread.Queue schedules code to run on the main thread (UI) without blocking the worker; Synchronize blocks and is often the cause of deadlocks in shutdown paths.
Source snippet: Delphi WebSocket client with Stop, Reconnect and Message-Dispatch
The following code is intentionally structured as an „operational component“: a class you can use similarly in VCL/FMX or in a Windows- and Windows- and Linux-services (depending on Delphi version/platform). The core is a worker thread that holds the receive loop and reports to the application via events.
unit Net.WebSocketClientEx;
interface
uses
System.SysUtils,
System.Classes,
System.SyncObjs,
System.Generics.Collections,
System.Net.URLClient,
System.Net.WebSockets;
type
TWsLogLevel = (llDebug, llInfo, llWarn, llError);
TWsLogEvent = reference to procedure(Level: TWsLogLevel; const Msg: string);
TWsTextEvent = reference to procedure(const Text: string);
TWsStateEvent = reference to procedure(const State: string);
TDelphiWebSocketClient = class
private
FUrl: string;
FOnLog: TWsLogEvent;
FOnText: TWsTextEvent;
FOnState: TWsStateEvent;
FStopEvent: TEvent;
FWorker: TThread;
FMinBackoffMs: Integer;
FMaxBackoffMs: Integer;
FHeartbeatSec: Integer;
procedure Log(Level: TWsLogLevel; const Msg: string);
procedure State(const S: string);
procedure Run;
function NextBackoffMs(const Prev: Integer): Integer;
function NowUtcStr: string;
public
constructor Create(const AUrl: string);
destructor Destroy; override;
procedure Start;
procedure Stop(TimeoutMs: Cardinal = 5000);
property OnLog: TWsLogEvent read FOnLog write FOnLog;
property OnText: TWsTextEvent read FOnText write FOnText;
property OnState: TWsStateEvent read FOnState write FOnState;
property MinBackoffMs: Integer read FMinBackoffMs write FMinBackoffMs;
property MaxBackoffMs: Integer read FMaxBackoffMs write FMaxBackoffMs;
property HeartbeatSec: Integer read FHeartbeatSec write FHeartbeatSec;
end;
implementation
type
TBytesBuffer = record
Data: TBytes;
Len: Integer;
end;
{ TDelphiWebSocketClient }
constructor TDelphiWebSocketClient.Create(const AUrl: string);
begin
inherited Create;
FUrl := AUrl;
FStopEvent := TEvent.Create(nil, True, False, '');
FMinBackoffMs := 500;
FMaxBackoffMs := 15000;
FHeartbeatSec := 20; // App heartbeat: useful against idle timeouts behind proxies
end;
destructor TDelphiWebSocketClient.Destroy;
begin
Stop;
FStopEvent.Free;
inherited;
end;
procedure TDelphiWebSocketClient.Start;
begin
if Assigned(FWorker) then
Exit;
FStopEvent.ResetEvent;
FWorker := TThread.CreateAnonymousThread(
procedure
begin
Run;
end);
FWorker.FreeOnTerminate := False;
FWorker.Start;
end;
procedure TDelphiWebSocketClient.Stop(TimeoutMs: Cardinal);
var
W: TThread;
begin
FStopEvent.SetEvent;
W := FWorker;
if Assigned(W) then
begin
if W.WaitFor(TimeoutMs) = wrTimeout then
Log(llWarn, 'Stop: worker did not respond within timeout; possible block in network stack');
FreeAndNil(FWorker);
end;
end;
procedure TDelphiWebSocketClient.Log(Level: TWsLogLevel; const Msg: string);
begin
if Assigned(FOnLog) then
TThread.Queue(nil,
procedure
begin
FOnLog(Level, NowUtcStr + ' ' + Msg);
end);
end;
procedure TDelphiWebSocketClient.State(const S: string);
begin
if Assigned(FOnState) then
TThread.Queue(nil,
procedure
begin
FOnState(S);
end);
end;
function TDelphiWebSocketClient.NowUtcStr: string;
begin
Result := FormatDateTime('yyyy-mm-dd"T"hh:nn:ss.zzz"Z"', TTimeZone.Local.ToUniversalTime(Now));
end;
function TDelphiWebSocketClient.NextBackoffMs(const Prev: Integer): Integer;
var
N: Integer;
begin
if Prev <= 0 then
Exit(FMinBackoffMs);
N := Prev * 2;
if N < FMinBackoffMs then N := FMinBackoffMs;
if N > FMaxBackoffMs then N := FMaxBackoffMs;
Result := N;
end;
procedure TDelphiWebSocketClient.Run;
var
WS: TClientWebSocket;
Backoff: Integer;
LastHeartbeat: UInt64;
Msg: string;
Buf: TBytes;
Received: TWebSocketReceiveResult;
SB: TStringBuilder;
WaitRes: TWaitResult;
function StopRequested: Boolean;
begin
Result := (FStopEvent.WaitFor(0) = wrSignaled);
end;
procedure SafeClose;
begin
try
if WS.State = TWebSocketState.Open then
WS.Close(TWebSocketCloseStatus.NormalClosure, 'client shutdown');
except
on E: Exception do
Log(llDebug, 'Close: ' + E.ClassName + ': ' + E.Message);
end;
end;
begin
Backoff := 0;
LastHeartbeat := 0;
while not StopRequested do
begin
WS := TClientWebSocket.Create;
try
State('connecting');
Log(llInfo, 'Connect to ' + FUrl);
try
// Note: TClientWebSocket.Connect is synchronous and may block depending on DNS/TLS.
// Therefore it runs here in the worker.
WS.Connect(FUrl);
except
on E: Exception do
begin
State('connect_failed');
Log(llWarn, 'Connect failed: ' + E.ClassName + ': ' + E.Message);
Backoff := NextBackoffMs(Backoff);
WaitRes := FStopEvent.WaitFor(Backoff);
if WaitRes = wrSignaled then Break;
Continue;
end;
end;
State('open');
Backoff := 0;
SetLength(Buf, 16 * 1024);
SB := TStringBuilder.Create;
try
while (WS.State = TWebSocketState.Open) and (not StopRequested) do
begin
// Heartbeat as an application message because Ping/Pong is not cleanly exposed in every Delphi version.
if (FHeartbeatSec > 0) then
begin
if (LastHeartbeat = 0) or (TThread.GetTickCount64 - LastHeartbeat >= UInt64(FHeartbeatSec) * 1000) then
begin
try
WS.Send('ping');
LastHeartbeat := TThread.GetTickCount64;
Log(llDebug, 'Heartbeat ping sent');
except
on E: Exception do
begin
Log(llWarn, 'Heartbeat send failed: ' + E.Message);
Break;
end;
end;
end;
end;
// Receive: frame-based, therefore use a StringBuilder for fragmentation.
try
Received := WS.Receive(Buf);
except
on E: Exception do
begin
Log(llWarn, 'Receive failed: ' + E.ClassName + ': ' + E.Message);
Break;
end;
end;
case Received.Kind of
TWebSocketMessageKind.Text:
begin
SB.Append(TEncoding.UTF8.GetString(Buf, 0, Received.BytesReceived));
if Received.EndOfMessage then
begin
Msg := SB.ToString;
SB.Clear;
if Assigned(FOnText) then
TThread.Queue(nil,
procedure
begin
FOnText(Msg);
end);
end;
end;
TWebSocketMessageKind.Binary:
begin
// In many business protocols, text/JSON is standard.
// Binary can be buffered similarly here or forwarded directly.
Log(llDebug, 'Binary frame received: ' + Received.BytesReceived.ToString + ' bytes');
end;
TWebSocketMessageKind.Close:
begin
Log(llInfo, 'Server requested close');
Break;
end;
end;
// Mini-sleep to spare CPU in very fast loops.
// Not too large, otherwise latency degrades.
TThread.Sleep(1);
end;
finally
SB.Free;
end;
SafeClose;
State('closed');
finally
WS.Free;
end;
if StopRequested then
Break;
// Reconnect after clean close or after errors
Backoff := NextBackoffMs(Backoff);
Log(llInfo, 'Reconnect in ' + Backoff.ToString + ' ms');
WaitRes := FStopEvent.WaitFor(Backoff);
if WaitRes = wrSignaled then
Break;
end;
State('stopped');
Log(llInfo, 'Worker stopped');
end;
end.
What the code intentionally does „differently“ compared to typical examples
- Stop without force: Instead of killing threads, Stop sets an event. The worker terminates loops at defined points. This reduces hangs during shutdown and prevents resource leaks in the socket stack.
- Queue instead of Synchronize: Logging and events are sent to the main thread via TThread.Queue. This is important when Stop/Shutdown originates from the UI or from service-control handlers. Synchronize can block if the main thread is currently waiting.
- Fragmentation considered: WebSocket text can arrive fragmented across frames. Hence the TStringBuilder and the check for EndOfMessage.
- Heartbeat as an application protocol: Many setups die from idle timeouts (load balancers, nginx, Cloud WAF). A lightweight „ping“ text often serves as an operational mechanism more effectively than relying on „TCP keepalive“ or a Ping/Pong API that is not universally available.
Constraints and operational pitfalls
1) DNS, TLS and proxy: Connect can block
TClientWebSocket.Connect is synchronous. Depending on DNS resolution, TLS handshake, certificate validation or proxy environment, this can take several seconds. The code deliberately runs it in a worker. If you need hard timeouts in addition, you must check at the API level whether your Delphi version provides timeout options, or you encapsulate Connect in a separate thread and abort via process logic. Important: an „abort“ here usually means „mark the connection as broken and recreate the worker“, not „immediately kill the socket operation“.
2) Idle timeouts: why a heartbeat is often mandatory
In corporate networks a WebSocket is often terminated behind a reverse proxy (nginx, IIS ARR) or a load balancer. Many of these components close connections if no data flows for extended periods. TCP keepalive is not always configured short enough (and under Windows often measured in minutes rather than seconds). An application-level heartbeat is therefore a reliable workaround. Ensure that server and client share the same concept (e.g. „ping“/“pong“ as text or JSON).
3) Threading and UI: Events must remain decoupled
If the OnText processing is heavy (JSON parsing, DB accesses with BDE-replacement with native binding, UI updates), it should not block the main thread. The wrapper only delivers the message. A typical pattern is: OnText places the payload into a queue (e.g. TThreadedQueue<string>), and a separate worker processes it with backpressure (i.e. limited queue length). This prevents the UI from freezing or the receiver from falling behind during burst load.
Debugging: what you should log when it „sometimes“ disconnects
WebSockets are notorious for „running for days, then failing.“ Without logging this is hard to narrow down. Useful log points:
- Timestamp (UTC), URL, and state transitions (connecting/open/closed).
- Close reason, if available (server-initiated close vs. network error).
- Heartbeat send failures and receive exceptions including exception type.
- Optional: sizes of received messages (not the contents), to detect data explosions.
If you terminate over TLS: additionally check whether certificate changes (expiration, new issuer) correlate in time with errors. In hardened environments, proxy and DPI boxes (Deep Packet Inspection) are also candidates.
Variants: when System.Net.WebSockets is sufficient — and when it is not
System.Net.WebSockets is sufficient for many integration cases, especially when dealing with text/JSON, moderate load and clear reconnect strategies. Limits depend on Delphi generation and target platform:
- Missing/limited Ping/Pong support: then an app heartbeat remains the robust pattern.
- Missing timeouts/cancellation in Connect/Receive: then you must design the architecture so that a stuck worker remains isolated and the application can still shut down cleanly (e.g. via a process watchdog or separate worker instances).
- High load or binary streams: then a stronger framing/buffering concept is worthwhile (e.g. ring buffer, separate binary event, message assembler with limits).
For legacy situations (older Delphi generations, very specific TLS/Proxy requirements) libraries like ICS are in some projects more pragmatic. What matters less is „which library“, and more that you treat shutdown, reconnect and observability (logs/metrics) as first-class concerns.
Conclusion: a Delphi WebSocket client is an operational building block — with clear limits
A WebSocket is well suited for push events, live status, machine or process messages and as a return channel for portals and services. The presented wrapper focuses on the points that often make the difference in digital enterprise solutions: controlled reconnect, heartbeat against idle timeouts, fragment-safe text processing and a stop path that does not hang during deployment or update.
Limits remain: if you need hard guarantees for Connect/Receive abort within very tight time windows or operate at extremely high data rates, you must go deeper into timeouts, platform peculiarities and, if necessary, alternative stacks. For the majority of integration and modernization scenarios, however, a cleanly encapsulated, well-logged client like the one above is a solid basis that can be integrated into mature Delphi systems.
If you want to fit such a building block into an existing architecture (e.g. Layer-3 architecture with clear service and UI layers) or need to debug sporadic disconnects under real conditions, you can assess that with us: contact us.
In the technical context, heartbeat Ping/Pong also play an important role when integrations, data flows and further development must interact cleanly.
Discuss a project or modernization initiative with Net-Base.
Next step
When the topic becomes a real project, architecture, the existing system landscape and operations should be considered together early on.
We support not only with individual issues, but also when source snippets, legacy topics, or portal ideas are to be turned into a robust enterprise project.
- Current state, target state and technical risks are assessed jointly.
- REST, data access, portals and rollout are not deferred as afterthoughts.
- You can determine early which path is economically and operationally viable.