Case Study 2: Building a Responsive GUI

Overview

In this case study, we solve the "frozen GUI" problem — the most common motivation for multithreading in desktop applications. We take a Lazarus application that freezes during a long-running operation and refactor it to use a background thread, keeping the UI responsive with a progress bar and a cancel button.


The Problem: The Frozen Form

Rosa opens PennyWise and clicks "Generate Annual Report." The report requires reading 12 months of data, computing statistics, and formatting the output. It takes 8 seconds. During those 8 seconds:

  • The window title shows "(Not Responding)"
  • The progress bar is frozen
  • The Cancel button does not respond to clicks
  • Moving or resizing the window produces visual artifacts
  • The operating system might offer to kill the application

This happens because the report generation runs in the main thread — the same thread that processes GUI events (mouse clicks, window redraws, keyboard input). While the main thread is busy computing, it cannot process events.


The Solution Architecture

Main Thread (GUI)                  Worker Thread
     |                                  |
     | User clicks "Generate"           |
     |                                  |
     | Creates TReportThread            |
     | Starts it                        |
     | Returns to event loop            |
     |          <-- continues processing GUI events -->
     |                                  |
     |    Queue(UpdateProgress)     <--- Worker computes
     | Progress bar updates             |
     |                                  |
     |    Queue(UpdateProgress)     <--- Worker computes
     | Progress bar updates             |
     |                                  |
     | User clicks Cancel               |
     | Sets Thread.Terminate            |
     |                                  | Checks Terminated
     |                                  | Exits gracefully
     |                                  |
     |    Queue(ReportComplete)    <--- Worker finishes
     | Displays results                 |

The key insight: the main thread never does heavy computation. It starts the worker, returns to the event loop, and processes GUI updates from the worker via Queue.


The Worker Thread

type
  TReportThread = class(TThread)
  private
    FProgressPercent: Integer;
    FStatusText: string;
    FReportHTML: string;
    FSuccess: Boolean;
    FOnProgress: TNotifyEvent;
    FOnComplete: TNotifyEvent;
    procedure DoProgress;
    procedure DoComplete;
  protected
    procedure Execute; override;
  public
    property ProgressPercent: Integer read FProgressPercent;
    property StatusText: string read FStatusText;
    property ReportHTML: string read FReportHTML;
    property Success: Boolean read FSuccess;
    property OnProgress: TNotifyEvent write FOnProgress;
    property OnComplete: TNotifyEvent write FOnComplete;
  end;

procedure TReportThread.DoProgress;
begin
  if Assigned(FOnProgress) then
    FOnProgress(Self);
end;

procedure TReportThread.DoComplete;
begin
  if Assigned(FOnComplete) then
    FOnComplete(Self);
end;

procedure TReportThread.Execute;
var
  Month: Integer;
begin
  FSuccess := False;

  try
    for Month := 1 to 12 do
    begin
      if Terminated then
      begin
        FStatusText := 'Cancelled by user.';
        Queue(@DoComplete);
        Exit;
      end;

      FProgressPercent := (Month * 100) div 12;
      FStatusText := Format('Processing %s...', [
        FormatDateTime('mmmm', EncodeDate(2026, Month, 1))
      ]);
      Queue(@DoProgress);

      { Simulate month processing (replace with real computation) }
      Sleep(600);
    end;

    { Generate final report }
    FStatusText := 'Formatting report...';
    FProgressPercent := 100;
    Queue(@DoProgress);

    FReportHTML := '<h1>Annual Report</h1><p>12 months processed.</p>';
    FSuccess := True;
    FStatusText := 'Report complete.';
    Queue(@DoComplete);

  except
    on E: Exception do
    begin
      FStatusText := 'Error: ' + E.Message;
      FSuccess := False;
      Queue(@DoComplete);
    end;
  end;
end;

The Form Code

type
  TMainForm = class(TForm)
    BtnGenerate: TButton;
    BtnCancel: TButton;
    ProgressBar: TProgressBar;
    StatusLabel: TLabel;
    ReportMemo: TMemo;
    procedure BtnGenerateClick(Sender: TObject);
    procedure BtnCancelClick(Sender: TObject);
  private
    FReportThread: TReportThread;
    procedure OnReportProgress(Sender: TObject);
    procedure OnReportComplete(Sender: TObject);
  end;

procedure TMainForm.BtnGenerateClick(Sender: TObject);
begin
  { Prevent starting multiple threads }
  if FReportThread <> nil then Exit;

  { Update UI state }
  BtnGenerate.Enabled := False;
  BtnCancel.Enabled := True;
  ProgressBar.Position := 0;
  StatusLabel.Caption := 'Starting report generation...';
  ReportMemo.Clear;

  { Create and start the worker thread }
  FReportThread := TReportThread.Create(True);
  FReportThread.FreeOnTerminate := False;
  FReportThread.OnProgress := @OnReportProgress;
  FReportThread.OnComplete := @OnReportComplete;
  FReportThread.Start;
end;

procedure TMainForm.BtnCancelClick(Sender: TObject);
begin
  if FReportThread <> nil then
  begin
    FReportThread.Terminate;
    StatusLabel.Caption := 'Cancelling...';
    BtnCancel.Enabled := False;
  end;
end;

procedure TMainForm.OnReportProgress(Sender: TObject);
begin
  { This runs in the main thread — safe to update GUI }
  ProgressBar.Position := FReportThread.ProgressPercent;
  StatusLabel.Caption := FReportThread.StatusText;
end;

procedure TMainForm.OnReportComplete(Sender: TObject);
begin
  { This runs in the main thread }
  ProgressBar.Position := 100;
  StatusLabel.Caption := FReportThread.StatusText;

  if FReportThread.Success then
    ReportMemo.Text := FReportThread.ReportHTML
  else
    ReportMemo.Text := 'Report generation failed or was cancelled.';

  { Clean up }
  FReportThread.WaitFor;
  FreeAndNil(FReportThread);

  { Reset UI state }
  BtnGenerate.Enabled := True;
  BtnCancel.Enabled := False;
end;

Critical Design Decisions

1. FreeOnTerminate = False

We set FreeOnTerminate := False because we need to read the thread's properties (ReportHTML, Success) after it finishes. If FreeOnTerminate were True, the object would be freed before we could read the results.

2. Queue Instead of Synchronize

We use Queue for progress updates because: - The worker does not need to wait for the GUI update to complete - Synchronize would block the worker on every progress update, slowing it down - Queue is fire-and-forget — the worker continues immediately

3. Cancel via Terminate

The Cancel button calls FReportThread.Terminate, which sets Terminated := True. The worker checks Terminated at the beginning of each month's processing. The cancellation is cooperative — the worker finishes its current unit of work, then exits cleanly.

4. Single Thread Guard

The if FReportThread <> nil then Exit check prevents creating multiple worker threads. Clicking "Generate" while a report is already running does nothing.


Before and After

Behavior Before (main thread) After (worker thread)
GUI during processing Frozen Responsive
Progress bar Static Updates every month
Cancel button Non-functional Works immediately
Window resize Breaks Smooth
"(Not Responding)" Yes No
Error handling Crash (no cleanup) Graceful message

Lessons Learned

  1. Never do heavy work in the main thread. If an operation takes more than ~200ms, put it in a worker thread. The user notices delays over 100ms.

  2. Queue is almost always better than Synchronize for progress updates. The worker should not wait for the GUI to repaint.

  3. Guard against multiple threads. Always check if a thread is already running before starting a new one.

  4. Clean up in the completion handler. WaitFor and Free the thread in the OnComplete callback, after reading all results.

  5. Handle exceptions in the worker. An unhandled exception in a thread silently terminates it. Wrap Execute in try-except and report errors through the completion mechanism.