Net-Base Magazine

01.06.2026

Delphi WebSocket Client: connect robustly, stop cleanly, debug reliably

A Delphi WebSocket client can quickly be "somehow connected" — but in operation reconnection, heartbeats, clean shutdown and debuggability matter. With a practical, production-grade wrapper based on System.Net.WebSockets (with fallback) and a source snippet for threading and...

01.06.2026

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.

Delphi
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.

Share post

Share this post directly

LinkedIn, X, XING, Facebook, WhatsApp and email are available immediately. For Instagram, we will prepare the link and a short caption immediately.

Email

Instagram opens in a new tab. The link and short text are copied to the clipboard beforehand.