Net-Base Magazin

01.06.2026

Delphi WebSocket Client: robust verbinden, sauber stoppen, zuverlässig debuggen

Ein Delphi WebSocket Client ist schnell verbunden – aber im Betrieb zählen Stop ohne Deadlock, Heartbeat gegen Proxy-Timeouts, Fragment-Reassembly und Reconnect mit Backoff. Source-Schnipsel inklusive.

01.06.2026

Vom Magazinthema zur Projektpraxis

Passende Leistungs- und Technikseiten zum Beitrag

„Mein Delphi WebSocket Client verbindet – aber nach ein paar Stunden kommen keine Events mehr. Und beim Beenden hängt der Prozess manchmal.“ Diese Leserfrage taucht in Echtbetriebsszenarien häufiger auf als „Wie baue ich einen WebSocket?“. Denn WebSockets sind langlebige, zustandsbehaftete Verbindungen; damit kippen Fehlerbilder von „ein Request ist fehlgeschlagen“ zu „der ganze Client ist in einem undefinierten Zustand“.

Konstruierte, aber realistische Situation: Sie modernisieren eine gewachsene VCL-Anwendung, die Statusänderungen aus einer Server-Komponente live anzeigen soll (z. B. Maschinenmeldungen, Benutzeraktionen, Portal-Events). Im Testnetz läuft alles. Im Kundennetz hängt davor ein Reverse Proxy (typisch: TLS-Termination, Session-Affinity), Clients hängen teils in VPNs, teils hinter NAT. Nach Feierabend ist wenig Verkehr, am Morgen fehlt der Live-Status. Und beim Rollout eines Updates bleibt ein Client-Prozess stehen, weil ein Receive-Loop noch blockiert.

Dieser Refresh ist deshalb kein „Hello World“, sondern ein betriebsorientierter Source-Schnipsel: ein Wrapper um System.Net.WebSockets (Delphi RTL; WebSocket-Client-API mit TClientWebSocket), der Start/Stop, Reconnect-Backoff, Heartbeat und Debug-Hooks sauber kapselt. Nebenbei räumen wir eine verbreitete Fehlannahme auf: Nicht der WebSocket an sich ist instabil – instabil ist oft die Umgebung (Proxies, Idle-Timeouts, DNS/TLS, Threading).

Delphi WebSocket Client: Was im Betrieb wirklich entscheidet

Begriffe kurz eingeordnet, weil sie im Code zentral sind:

  • Backoff: eine Wartezeit, die nach Fehlern kontrolliert wächst (z. B. 0,5s → 1s → 2s …). Ziel: Server und Netzwerk nicht „hammern“, aber zügig wieder online sein.
  • Heartbeat: regelmäßige, kleine Nachrichten auf Anwendungsebene, um Idle-Timeouts zu vermeiden und „lebt noch“ zu signalisieren (wenn Ping/Pong nicht zuverlässig verfügbar ist).
  • TThread.Queue vs. Synchronize: Queue plant Code im Mainthread ein, blockiert den Worker nicht; Synchronize blockiert und ist im Shutdown-Pfad ein häufiger Deadlock-Auslöser.
  • Fragmentierung: WebSocket-Text kann über mehrere Frames kommen. „Ein Receive = ein JSON“ ist eine Annahme, die im Fehlerfall weh tut.

Für die API-Grundlagen lohnt ein Blick in die Delphi-Doku zur RTL-Einheit System.Net.WebSockets, um die Zustände und Message-Kinds korrekt zu interpretieren (Embarcadero DocWiki: System.Net.WebSockets). Und für das Threading-Verhalten ist die Unterscheidung Queue/Synchronize entscheidend, weil Sie damit Shutdown-Deadlocks vermeiden (Embarcadero DocWiki: TThread.Queue).

Source-Schnipsel: Wrapper mit Stop-Signal, Backoff und Fragment-Reassembly

Der Code ist bewusst so geschnitten, dass er in VCL/FMX und auch in Service-Prozessen funktioniert. Der Kern ist ein Worker-Thread; Stop erfolgt über TEvent (Abbruchsignal), nicht über „Thread kill“.

Delphi
unit Net.WebSocketClientEx;

interface

uses
  System.SysUtils,
  System.Classes,
  System.SyncObjs,
  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;
    FMaxMessageBytes: 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;
    property MaxMessageBytes: Integer read FMaxMessageBytes write FMaxMessageBytes;
  end;

implementation

uses
  System.NetEncoding;

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 gegen Idle-Timeouts
  FMaxMessageBytes := 2*1024*1024; // Schutz gegen Payload-Explosion
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 reagiert nicht innerhalb Timeout (Connect/Receive könnte blockieren)');
    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;
  Buf: TBytes;
  Received: TWebSocketReceiveResult;
  SB: TStringBuilder;
  AssembledBytes: Integer;

  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;

  procedure DispatchText(const S: string);
  begin
    if Assigned(FOnText) then
      TThread.Queue(nil,
        procedure
        begin
          FOnText(S);
        end);
  end;

begin
  Backoff := 0;
  LastHeartbeat := 0;

  while not StopRequested do
  begin
    WS := TClientWebSocket.Create;
    try
      State('connecting');
      Log(llInfo, 'Connect: ' + FUrl);

      try
        // Connect ist synchron und kann bei DNS/TLS/Proxy blockieren.
        // Deshalb: immer im Worker, nie im Mainthread.
        WS.Connect(FUrl);
      except
        on E: Exception do
        begin
          State('connect_failed');
          Log(llWarn, 'Connect failed: ' + E.ClassName + ': ' + E.Message);
          Backoff := NextBackoffMs(Backoff);
          if FStopEvent.WaitFor(Backoff) = wrSignaled then Break;
          Continue;
        end;
      end;

      State('open');
      Backoff := 0;

      SetLength(Buf, 16 * 1024);
      SB := TStringBuilder.Create;
      AssembledBytes := 0;
      try
        while (WS.State = TWebSocketState.Open) and (not StopRequested) do
        begin
          // App-Heartbeat: bewusst als Text, weil Ping/Pong nicht überall gleich gut nutzbar ist.
          if (FHeartbeatSec > 0) then
          begin
            if (LastHeartbeat = 0) or
               (TThread.GetTickCount64 - LastHeartbeat >= UInt64(FHeartbeatSec) * 1000) then
            begin
              try
                WS.Send('{"type":"ping"}');
                LastHeartbeat := TThread.GetTickCount64;
                Log(llDebug, 'Heartbeat sent');
              except
                on E: Exception do
                begin
                  Log(llWarn, 'Heartbeat send failed: ' + E.Message);
                  Break;
                end;
              end;
            end;
          end;

          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));
                Inc(AssembledBytes, Received.BytesReceived);

                // Schutz gegen „unendliche“ Nachrichten (z. B. Serverbug, Protokollbruch)
                if (FMaxMessageBytes > 0) and (AssembledBytes > FMaxMessageBytes) then
                begin
                  Log(llWarn, 'Message too large; closing connection');
                  Break;
                end;

                if Received.EndOfMessage then
                begin
                  DispatchText(SB.ToString);
                  SB.Clear;
                  AssembledBytes := 0;
                end;
              end;

            TWebSocketMessageKind.Binary:
              begin
                // In vielen Integrationen ist Text/JSON der Standard.
                // Wenn Binary gebraucht wird: separates Event + eigener Assembler + Limits.
                Log(llDebug, 'Binary frame: ' + Received.BytesReceived.ToString + ' bytes');
              end;

            TWebSocketMessageKind.Close:
              begin
                Log(llInfo, 'Server requested close');
                Break;
              end;
          end;

          TThread.Sleep(1);
        end;
      finally
        SB.Free;
      end;

      SafeClose;
      State('closed');

    finally
      WS.Free;
    end;

    if StopRequested then Break;

    Backoff := NextBackoffMs(Backoff);
    Log(llInfo, 'Reconnect in ' + Backoff.ToString + ' ms');
    if FStopEvent.WaitFor(Backoff) = wrSignaled then Break;
  end;

  State('stopped');
  Log(llInfo, 'Worker stopped');
end;

end.

Warum dieser Aufbau sich in Legacy- und Mischarchitekturen bewährt

Ungewöhnlich (und bewusst) sind zwei Punkte: Erstens wird der Heartbeat als Anwendungsnachricht modelliert. Das ist nicht „schön“, aber robust, wenn Sie nicht sicher sind, ob Ihre Delphi-Version Ping/Pong sauber exponiert oder ob Proxies damit korrekt umgehen. Zweitens ist MaxMessageBytes ein Sicherheitsgurt: In Business-Software sieht man durchaus Fälle, in denen eine Serverkomponente auf einmal riesige Payloads pusht (Bug oder Fehlkonfiguration). Ohne Limit frisst Ihr Client RAM und hängt dann scheinbar „zufällig“.

Shutdown, Deadlocks und die Queue/Synchronize-Falle

Wenn ein Client beim Stoppen hängt, sind meist zwei Ursachen im Spiel: (1) ein blockierender Connect/Receive im Netzwerk-Stack und (2) ein Callback, der via Synchronize in den Mainthread will, während der Mainthread gerade auf das Ende des Workers wartet. Das ist ein klassischer Deadlock-Ring.

Hier ist der Wrapper strikt: Ereignisse gehen über TThread.Queue, der Worker blockiert nicht auf UI-Ausführung. Das löst nicht jedes Problem (ein blockierender Connect kann trotzdem länger hängen), aber es verhindert die selbstgebauten Deadlocks.

Reconnect-Strategie: Backoff ist kein „Nice to have“

Ein WebSocket, der im Fehlerfall im 200ms-Takt reconnectet, ist im Kundennetz eine Einladung zu Nebeneffekten: Proxy-Rate-Limits, Log-Fluten, unnötige TLS-Handshakes und schwer interpretierbare Fehlerbilder. Backoff macht das Verhalten berechenbar.

Heartbeat, Reverse Proxy und TLS: wo WebSockets „plötzlich“ sterben

Idle-Timeouts sind der häufigste Grund für „morgens keine Updates mehr“. Reverse Proxies oder Load Balancer schließen gern Verbindungen, wenn längere Zeit keine Daten fließen. TCP-Keepalive ist oft zu träge konfiguriert, und Sie haben als Applikation nicht immer die Hoheit über OS-Settings.

Der Heartbeat ist deshalb weniger Protokollspielerei als Betriebswerkzeug: Sie erzeugen definierten Traffic, und Sie können serverseitig ausbleibende Heartbeats als Signal für „Client weg“ nutzen. Wichtig: Heartbeat-Frequenz und Payload klein halten; im Zweifel ist alle 20–60 Sekunden ein pragmatischer Bereich, abhängig von Infrastruktur und Kostenmodell.

Vergleich: RTL-WebSocket vs. Alternativen (wann ein Wechsel lohnt)

Kriterium System.Net.WebSockets (RTL) Alternativer Stack (z. B. ICS)
Integration/Deployment Keine Zusatzlibs, „im Projekt drin“ Zusätzliche Abhängigkeit, dafür mehr Stellschrauben
Threading-Modell Erfordert saubere Kapselung (Worker), Connect/Receive oft synchron Je nach Library mehr Kontrolle über asynchrone APIs/Timeouts
Ping/Pong, Timeouts Version/Plattform-abhängig; oft App-Heartbeat nötig Häufig granularer konfigurierbar
Legacy-Delphi Je nach Version eingeschränkt oder nicht verfügbar Oft bessere Abdeckung älterer Toolchains

Genau eine redaktionelle Einschätzung (begründet)

Unsere Einschätzung: Für die meisten Modernisierungs- und Integrationsszenarien in individueller Unternehmenssoftware lohnt sich zuerst die Disziplin in Architektur und Betrieb (Stop-Signal, Queue, Limits, Backoff, Logging) – erst danach die Diskussion „welche Library“. Der Grund ist simpel: Die häufigsten Ausfälle entstehen nicht durch fehlende Features, sondern durch fehlende Betriebs-Mechanik (Idle-Timeout, Shutdown-Deadlock, unlimitierte Payloads, fehlende Tracebarkeit). Ein Stack-Wechsel kann nötig werden, aber er ersetzt diese Mechanik nicht.

Risiken und Einsatzgrenzen

  • Keine harte Cancellation: Wenn Connect/Receive in Ihrer Umgebung sehr lange blockieren kann, brauchen Sie zusätzliche Architekturmaßnahmen (z. B. Prozess-Überwachung, getrennte Worker-Instanz, „fail closed“ beim Update).
  • UI-Überlast: Wenn OnText zu schwer wird, hilft Queue/Backpressure (TThreadedQueue) und ein separater Consumer-Worker.
  • Protokoll-Disziplin: Heartbeat und Message-Formate müssen mit dem Server abgestimmt sein (z. B. JSON envelope, Versionierung).
  • Security/TLS: Zertifikatswechsel, TLS-Termination und Proxy-Regeln sind Betriebsrealität. Ohne Logging (Statewechsel, Exceptions, Close) debuggen Sie im Blindflug.

Fazit: Der Delphi WebSocket Client ist ein Betriebsbaustein

Wenn ein WebSocket nur „verbinden und lesen“ soll, ist er schnell gebaut. Wenn er über Nacht stabil laufen, Updates überleben und bei sporadischen Netzwerkproblemen deterministisch reagieren muss, wird er zu einem Baustein mit klaren Regeln: Stop-Signal statt Gewalt, Queue statt Synchronize, Fragment-Reassembly, Heartbeat gegen Idle-Timeouts, Backoff gegen Reconnect-Stürme und Limits gegen Payload-Fehler.

Wenn Sie so einen Client in eine bestehende Architektur einpassen (z. B. klare Schichten, Service-/UI-Trennung) oder echte Disconnect-Phänomene im Kundennetz reproduzierbar machen müssen, ist eine strukturierte Analyse oft schneller als Trial-and-Error. Kontakt aufnehmen und den konkreten Fall mit Net-Base besprechen.

Für dieses Thema sind auch Heartbeat Ping/Pong wichtig. Der Beitrag ordnet diese Aspekte verständlich ein und zeigt, worauf es im Alltag ankommt.

Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.

nächster Schritt

Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.

Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.

  • Bestand, Zielbild und technische Risiken werden zusammen bewertet.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.

Beitrag teilen

Diesen Beitrag direkt weitergeben

LinkedIn, X, XING, Facebook, WhatsApp und E-Mail sind sofort verfügbar. für Instagram bereiten wir Link und Kurztext direkt vor.

E-Mail

Instagram oeffnet in einem neuen Tab. Link und Kurztext werden vorher in die Zwischenablage kopiert.