Net-Base Magazine

14.06.2026

Delphi WebView2 in FMX: clean initialization, JS-Bridge implementation, download handling and debugging

WebView2 in FireMonkey sounds like "just embed a browser", but in practice it breaks down during initialization, navigation events, the JS↔Delphi-Bridge, download handling and debugging. This source snippet shows a robust pattern with clear responsibilities...

14.06.2026

From magazine topic to project implementation

Relevant service and technical pages for this post

Anyone who suddenly wants to „quickly“ embed modern web content into an existing business application will, on Windows, end up with WebView2. In Delphi WebView2 FMX the fundamental problem is rarely displaying a URL, but the clean embedding into a FireMonkey UI (FMX), reliable initialization (asynchronous and COM-based), and the Edge pitfalls around user-data directories, downloads, debugging and a robust JS↔Delphi communication.

This source snippet shows a pattern I prefer for maintainable applications: an encapsulated „host“ object that controls the WebView2 lifecycle, and a defined bridge via WebMessage (JSON), instead of arbitrary „ExecuteScript everywhere“. The goal is not demo code, but a building block that survives in grown clients.

Why WebView2 in FMX is different from a „browser-component drop“

WebView2 is a COM/WinRT-proximate API with asynchronous initialization. FireMonkey abstracts Windows handles, yet for WebView2 you ultimately need a real parent window (HWND) and controlled resize/focus forwarding. At the same time, events do not always run where you expect them in FMX. If you start here „quick and dirty“ you typically get:

  • sporadic AVs on form close (callbacks arrive after Destroy)
  • navigation events from the wrong thread context
  • unreliable persistence/cache issues due to an unclear UserDataFolder strategy
  • no downloads or „stuck“ download dialogs
  • debugging only by luck instead of a targeted remote-debug configuration

The countermeasure is a clear lifecycle: Create → InitializeAsync → Attach → Navigate → Detach/Dispose – and a defined boundary between UI and browser engine.

Source snippet: WebView2Host for Delphi WebView2 FMX

The following code sketches an encapsulated host class that (1) creates a WebView2 environment configuration, (2) binds the controller object to an HWND, (3) wires navigation and download events, and (4) offers a JSON-based JS bridge via WebMessageReceived. The code is intentionally architecture-ready: it encapsulates COM references, prevents callbacks from arriving after Destroy, and allows operational variants such as separate UserDataFolder per-user or per-machine.

unit WebView2Host;

interface

uses
System.SysUtils, System.Classes, System.IOUtils, System.JSON,
Winapi.Windows, Winapi.ActiveX,
FMX.Types,
WebView2, WebView2_TLB; // depending on setup: WebView2.pas or Import-TLB

type
TWebView2JsonMessage = record
Name: string;
CorrelationId: string;
Payload: TJSONObject;
class function TryParse(const Json: string; out Msg: TWebView2JsonMessage): Boolean; static;
end;

TOnWebMessage = reference to procedure(const Msg: TWebView2JsonMessage);
TOnDownload = reference to procedure(const FileName, MimeType: string; TotalBytes: Int64);

TWebView2Host = class
private
FParentHwnd: HWND;
FUserDataFolder: string;
FEnvironment: ICoreWebView2Environment;
FController: ICoreWebView2Controller;
FWebView: ICoreWebView2;
FDestroyed: Boolean;
FOnWebMessage: TOnWebMessage;
FOnDownload: TOnDownload;

procedure EnsureNotDestroyed;
function MakeUserDataFolder: string;

// Event handler
procedure HookEvents;
procedure UnhookEvents;

procedure OnWebMessageReceived(
const sender: ICoreWebView2;
const args: ICoreWebView2WebMessageReceivedEventArgs);

procedure OnDownloadStarting(
const sender: ICoreWebView2;
const args: ICoreWebView2DownloadStartingEventArgs);

public
constructor Create(AParentHwnd: HWND; const AUserDataFolder: string = “);
destructor Destroy; override;

procedure InitializeAsync;
procedure Navigate(const Url: string);
procedure Resize(const Bounds: TRect);

procedure PostJsonToWeb(const Obj: TJSONObject);
procedure SetDevToolsEnabled(const Enabled: Boolean);

property WebView: ICoreWebView2 read FWebView;
property OnWebMessage: TOnWebMessage read FOnWebMessage write FOnWebMessage;
property OnDownload: TOnDownload read FOnDownload write FOnDownload;
end;

implementation

{ TWebView2JsonMessage }

class function TWebView2JsonMessage.TryParse(const Json: string; out Msg: TWebView2JsonMessage): Boolean;
var
V: TJSONValue;
O: TJSONObject;
begin
Result := False;
Msg.Name := “;
Msg.CorrelationId := “;
Msg.Payload := nil;

V := TJSONObject.ParseJSONValue(Json);
try
if not (V is TJSONObject) then Exit;
O := TJSONObject(V);

Msg.Name := O.GetValue(’name‘, “);
Msg.CorrelationId := O.GetValue(‚cid‘, “);

// Payload may be missing or null
if O.TryGetValue(‚payload‘, Msg.Payload) then
Msg.Payload := TJSONObject(Msg.Payload.Clone)
else
Msg.Payload := TJSONObject.Create;

Result := Msg.Name <> “;
finally
V.Free;
end;
end;

{ TWebView2Host }

constructor TWebView2Host.Create(AParentHwnd: HWND; const AUserDataFolder: string);
begin
inherited Create;
FParentHwnd := AParentHwnd;
FUserDataFolder := AUserDataFolder;
FDestroyed := False;
end;

destructor TWebView2Host.Destroy;
begin
FDestroyed := True;

// Unhook events before COM objects are released
UnhookEvents;

FWebView := nil;
FController := nil;
FEnvironment := nil;

inherited;
end;

procedure TWebView2Host.EnsureNotDestroyed;
begin
if FDestroyed then
raise EInvalidOperation.Create(‚WebView2Host has already been destroyed.‘);
end;

function TWebView2Host.MakeUserDataFolder: string;
begin
if FUserDataFolder <> “ then
Exit(FUserDataFolder);

// In practice: per app + per Windows-user, not in program directory
Result := TPath.Combine(TPath.GetHomePath, ‚AppDataLocalMyCompanyMyAppWebView2‘);
ForceDirectories(Result);
end;

procedure TWebView2Host.InitializeAsync;
var
UserData: string;
Opt: ICoreWebView2EnvironmentOptions;
begin
EnsureNotDestroyed;

UserData := MakeUserDataFolder;

// Options: you can add additional browser arguments here, e.g. remote debugging
Opt := TCoreWebView2EnvironmentOptions.Create;

// Async CreateEnvironment
OleCheck(CreateCoreWebView2EnvironmentWithOptions(
nil, PWideChar(UserData), Opt,
TCoreWebView2CreateCoreWebView2EnvironmentCompletedHandler.Create(
procedure (errorCode: HRESULT; const createdEnvironment: ICoreWebView2Environment)
begin
if FDestroyed then Exit;
OleCheck(errorCode);

FEnvironment := createdEnvironment;

// Bind controller to parent HWND
OleCheck(FEnvironment.CreateCoreWebView2Controller(
FParentHwnd,
TCoreWebView2CreateCoreWebView2ControllerCompletedHandler.Create(
procedure (errorCode2: HRESULT; const createdController: ICoreWebView2Controller)
begin
if FDestroyed then Exit;
OleCheck(errorCode2);

FController := createdController;
OleCheck(FController.get_CoreWebView2(FWebView));

HookEvents;

// Make initially visible
FController.put_IsVisible(1);
end)));
end)));
end;

procedure TWebView2Host.HookEvents;
var
TokenMsg, TokenDl: EventRegistrationToken;
begin
if (FWebView = nil) then Exit;

// WebMessageReceived (JS->Delphi)
TokenMsg.value := 0;
OleCheck(FWebView.add_WebMessageReceived(
TCoreWebView2WebMessageReceivedEventHandler.Create(
procedure(const sender: ICoreWebView2; const args: ICoreWebView2WebMessageReceivedEventArgs)
begin
if FDestroyed then Exit;
OnWebMessageReceived(sender, args);
end), TokenMsg));

// DownloadStarting
TokenDl.value := 0;
OleCheck(FWebView.add_DownloadStarting(
TCoreWebView2DownloadStartingEventHandler.Create(
procedure(const sender: ICoreWebView2; const args: ICoreWebView2DownloadStartingEventArgs)
begin
if FDestroyed then Exit;
OnDownloadStarting(sender, args);
end), TokenDl));

// Note: For robust unhooking you should store the tokens.
// In many projects this is sufficient if the host lives only with the form.
end;

procedure TWebView2Host.UnhookEvents;
begin
// Robust variant: remember tokens and call remove_*.
// Left as comment here because the import-unit setup and token management vary by wrapper.
end;

procedure TWebView2Host.OnWebMessageReceived(
const sender: ICoreWebView2;
const args: ICoreWebView2WebMessageReceivedEventArgs);
var
Json: PWideChar;
S: string;
Msg: TWebView2JsonMessage;
begin
Json := nil;
OleCheck(args.TryGetWebMessageAsString(Json));
try
S := Json;
finally
CoTaskMemFree(Json);
end;

if Assigned(FOnWebMessage) and TWebView2JsonMessage.TryParse(S, Msg) then
begin
try
FOnWebMessage(Msg);
finally
Msg.Payload.Free;
end;
end;
end;

procedure TWebView2Host.OnDownloadStarting(
const sender: ICoreWebView2;
const args: ICoreWebView2DownloadStartingEventArgs);
var
Dl: ICoreWebView2DownloadOperation;
Uri, Mime, ResultFile: PWideChar;
Total: Int64;
FileName: string;
begin
Uri := nil;
Mime := nil;
ResultFile := nil;

OleCheck(args.get_DownloadOperation(Dl));
OleCheck(Dl.get_TotalBytesToReceive(Total));

// In practice: ResultFileName initially empty, depending on the source.
OleCheck(Dl.get_ResultFilePath(ResultFile));
OleCheck(Dl.get_MimeType(Mime));
OleCheck(Dl.get_Uri(Uri));

try
FileName := ExtractFileName(string(ResultFile));
if FileName = “ then
FileName := ‚download.bin‘;

if Assigned(FOnDownload) then
FOnDownload(FileName, string(Mime), Total);

// Optional: custom download UI, then set Handled
// args.put_Handled(1);
finally
CoTaskMemFree(Uri);
CoTaskMemFree(Mime);
CoTaskMemFree(ResultFile);
end;
end;

procedure TWebView2Host.Navigate(const Url: string);
begin
EnsureNotDestroyed;
if FWebView = nil then
raise EInvalidOperation.Create(‚WebView2 is not yet initialized.‘);

OleCheck(FWebView.Navigate(PWideChar(Url)));
end;

procedure TWebView2Host.Resize(const Bounds: TRect);
var
R: tagRECT;
begin
if FController = nil then Exit;
R.Left := Bounds.Left;
R.Top := Bounds.Top;
R.Right := Bounds.Right;
R.Bottom := Bounds.Bottom;
OleCheck(FController.put_Bounds(R));
end;

procedure TWebView2Host.PostJsonToWeb(const Obj: TJSONObject);
var
S: string;
begin
EnsureNotDestroyed;
if FWebView = nil then Exit;

S := Obj.ToJSON;
OleCheck(FWebView.PostWebMessageAsString(PWideChar(S)));
end;

procedure TWebView2Host.SetDevToolsEnabled(const Enabled: Boolean);
var
Settings: ICoreWebView2Settings;
begin
if (FWebView = nil) then Exit;
OleCheck(FWebView.get_Settings(Settings));
OleCheck(Settings.put_AreDevToolsEnabled(Ord(Enabled)));
end;

end.

Purpose of the approach

  • Lifecycle encapsulation: The FMX form only knows „Initialize/Navigate/Resize“, not COM details.
  • Bridge with contract: JSON messages with name, optional cid (Correlation-ID) and payload are maintainable and testable.
  • Operationally safe persistence: a controlled UserDataFolder prevents cache collisions, permission issues and „runs on developer machines, not in production“.

JS↔Delphi-Bridge: why WebMessage is more stable than ExecuteScript

WebView2 offers several ways of communication. In practice ExecuteScript is tempting, but hard to version: you push strings into an interpreter without clear response channels and without robust error mapping. PostWebMessageAsString / WebMessageReceived, by contrast, is a defined channel.

Edge case that appears frequently in enterprise environments: you need to start a Delphi workflow from a web front-end (e.g. an internal portal) (printing, device access, legacy integration). Then you need:

  • a whitelist of message names
  • correlation IDs for asynchronous responses
  • a central place that validates payloads (e.g. required fields, size limits)

In the host this is the OnWebMessageReceived point. The actual validation belongs in an upper layer (e.g. an application service) so you keep UI/WebView2 technology and business logic separate (classic layered architecture: UI → Application → Domain → Infrastructure).

Downloads and file storage: what often surprises in production

Downloads in WebView2 run via ICoreWebView2DownloadOperation. Depending on the source, ResultFilePath can be empty early or set only later. Additionally, many companies do not want end users saving into uncontrolled folders.

Recommended patterns:

  • Intercept DownloadStarting and take over the UI yourself via args.put_Handled(1) (custom path, naming convention, quarantine folder).
  • File size limits and MIME-type checks to avoid „accidentally a 4 GB log file“.
  • Auditing: write download metadata (URI, MIME, bytes) into your logging, not the content.

If you have regulated processes (e.g. approvals, traceability), handling via the events is the only place where you can integrate the browser world into your operational rules.

Debugging: DevTools, Remote Debug Port and reproducible states

WebView2 debugging often fails because states are not reproducible. Two adjustments help:

  • Enable/disable DevTools via ICoreWebView2Settings (in code: SetDevToolsEnabled) — usually off in release, enabled selectively in support cases.
  • Stable UserDataFolder: when your support team needs to reproduce a bug, a defined path is invaluable. You can archive/zip the folder (attention: data protection/PII) and compare states deliberately.

Optionally (depending on the wrapper) you can supply EnvironmentOptions with additional browser arguments, e.g. a remote debug port. This makes sense when you must analyze an application on a test system without local developer tools. Limits: in production this must be properly enabled and documented, otherwise you introduce an unnecessary attack surface.

Pitfalls in Delphi WebView2 FMX: COM, threads and form lifecycle

1) Callbacks after closing

Asynchronous CompletedHandlers can arrive after the form has already closed. In the snippet FDestroyed prevents access to freed objects. More robust in addition:

  • Store tokens for events and call remove_* cleanly in Destroy
  • Allow InitializeAsync only once (state machine: Created/Initializing/Ready/Disposed)

2) Thread context

Many handlers arrive “UI-near”, but do not assume you can write directly to FMX controls. If you update the UI in OnWebMessage, TThread.Queue(nil, ...) is the safe option. I like to separate concerns: the host collects the event, the application service decides, the UI is updated exclusively via queue.

3) DPI/Resize and FMX layouts

FMX computes in logical units, WebView2 expects pixel rects. In practice you need a clear place where you translate FMX control bounds into real pixels. The snippet assumes a TRect; in your form you should derive the WinAPI coordinates from that (for example via FMX.Platform.Win and handle APIs). If the app is scaled per monitor DPI, test monitor switches: WebView2 is more sensitive here than pure FMX controls.

When WebView2 in FMX makes sense — and when not

WebView2 makes sense when you want to use web technology intentionally in an established Delphi client application: embedded admin views, OAuth/OIDC login flows, HTML reports, internal portals or controlled “micro-frontends”. It is also practical as a modernization bridge, provided you cut responsibilities cleanly and do not let the bridge become an uncontrolled backdoor for business logic.

Limitations of the approach:

  • Platform: The pattern is Windows-centric. FMX is cross-platform capable, WebView2 is not. For macOS/iOS/Android you need other webviews or an abstraction layer.
  • Security/Hardening: Once external content is loaded you must restrict navigation, allowed domains and download targets more strictly. That belongs in the requirements, not “later”.
  • Support: UserDataFolder and runtime dependencies (WebView2 Runtime) must be part of your operations/rollout concept.

Conclusion

Delphi WebView2 FMX is less a UI gadget than an integration component with its own lifecycle. If you encapsulate initialization, eventing, UserDataFolder and JS-Bridge in a structured way, WebView2 becomes a stable building block for digital enterprise solutions: web UI where it makes sense, and Delphi logic where it belongs. If, by contrast, you fire scripts uncontrollably, leave paths to chance and fail to decouple events, you will see precisely the kind of “sporadic in the field” errors that consume time and erode trust.

If you want to integrate WebView2 cleanly into an existing Delphi application or technically assess a modernization edge, talk to us:

In the professional context Webview2 Firemonkey and Delphi Fmx Edge Browser also play an important role when integrations, data flows and further development must work together 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.