Rendering a page in PDFium is synchronous. You call into the library, it rasterises into a bitmap you handed it, and control comes back when the pixels are written. For a single screen-sized page at one zoom level that takes a few milliseconds and nobody notices. For a 300 dpi export of a 200 page document, or a thumbnail strip that has to rasterise every page at once, the same call costs seconds. If you make that call from the main thread, the message loop stops, the window stops repainting, and Windows paints the dreaded "Not Responding" over your title bar. The work is correct. The place you ran it is wrong
The fix is to move the long render onto a background thread and bring the result back to the main thread, where the bitmap can be handed to a control. PDFium itself does not stop you from doing this, but the binding has to make the handoff safe, because the bug surface around "run on a worker, reply on the UI" is wide and the failures are intermittent. The FPdfAsync unit in PDFiumPas exists to give that pattern one correct implementation, with a cancellation model that fits how a long render actually behaves
The shape of the work
Three operations dominate the cases where a render outlasts a frame. Batch rendering walks a page range and rasterises each page, usually to disk. Multi-page export does the same but assembles the output into one file. Background page rendering is what a viewer does when the user jumps to a page that is not in cache yet, so the bitmap is produced off-thread and shown when it is ready. All three share the same constraints. They run long enough that the UI thread cannot host them, they produce a result the UI thread eventually needs, and the user may abandon them. Closing the document, scrolling past the page, or pressing Cancel should stop the work instead of forcing the user to wait for output they no longer want
That last constraint is the one that shapes the design. A render that cannot be cancelled is a render that holds the document open and burns CPU after the answer stopped mattering. So the unit is built around two primitives that compose: a future that carries the result back, and a token that carries the cancellation request forward
A fire-and-forget future
TPdfFuture<T>.Run takes a worker, a reply, and an optional cancellation token. It starts the worker on a background thread, and when the worker finishes it delivers the reply on the main thread. The generic parameter T is whatever the render produces, often a bitmap handle or a status record. The worker runs off-thread; the reply runs where it is safe to touch the VCL
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
The deliberate omission is any kind of Wait. There is no method to block the caller until the future completes, and that is not an oversight. A Wait called from the main thread is the classic way to deadlock a UI: the worker needs the main thread to run its reply through Synchronize, the main thread is parked inside Wait, and neither side can proceed. By refusing to offer the primitive, the future rules out the pattern that most often defeats people who try to write this themselves. Code that genuinely needs to block should use a plain TThread and own the consequences. The future is for the fire-and-forget case, which is what background rendering actually is
The result is wrapped in TPdfFutureResult<T>, a record that tells the reply which of three things happened. IsSuccess means the worker returned normally and Value holds the render. IsCancelled means the token fired and the worker bailed out at a cancellation point. IsFailure means the worker raised, and ErrorMessage carries the text. The reply inspects the status once and branches, instead of guessing from a sentinel value whether a returned bitmap is real
The v1.61.0 race that changed reply delivery
The most instructive part of this unit is a one-line change that took a while to understand. Through early versions the worker thread delivered its reply with TThread.Queue. Queue posts the reply to the main thread's queue and returns immediately, which reads like exactly what a fire-and-forget future wants. It was wrong, and the reason is worth spelling out because it is the kind of bug that passes every test you think to write
The worker thread is created with FreeOnTerminate := True. That means the instant Execute returns, the thread tears itself down, and TThread.Destroy calls RemoveQueuedEvents(Self) as part of cleanup. RemoveQueuedEvents purges any queued method whose target is the dying thread. So the sequence was: the worker finishes, it queues the reply against itself, Execute returns, the thread destroys itself, and RemoveQueuedEvents deletes the reply that the main thread had not run yet. The result simply vanished. Worse, in the narrow window where the main thread pulled the queued reply off and started running it at the same moment the thread was being freed, the reply touched fields of a half-destroyed object, which is a use-after-free
The fix in v1.61.0 was to deliver the reply with Synchronize instead of Queue. Synchronize blocks the worker thread until the main thread has run the reply to completion. The worker is still alive while its reply executes, so there is nothing to free out from under it, and the thread does not return from Execute (and therefore does not start destroying itself) until the reply has been delivered. Delivery is guaranteed, and the use-after-free window is closed
procedure TPdfFutureThread<T>.Execute;
begin
FResult.Status := pfsSuccess;
FResult.ErrorMessage := '';
try
FToken.ThrowIfCancelled; // already cancelled? skip the worker
FResult.Value := FWorker(FToken);
except
on E: EPdfOperationCancelled do
begin
FResult.Status := pfsCancelled;
FResult.ErrorMessage := E.Message;
end;
on E: Exception do
begin
FResult.Status := pfsFailure;
FResult.ErrorMessage := E.Message;
end;
end;
if Assigned(FReply) then
// Synchronize, not Queue: this thread is FreeOnTerminate, so a queued reply
// could be dropped by RemoveQueuedEvents before the main thread ran it.
Synchronize(DispatchReply);
end;
The general lesson outlasts the specific fix. Fire-and-forget asynchronous callbacks are the easiest concurrency pattern to get subtly wrong, because the happy path works on the first try and the bug lives in the interaction between thread teardown order and the queue. It does not reproduce on demand. It depends on whether the main thread happened to drain the queue before the worker happened to finish destroying itself, which is a timing the scheduler decides differently every run. A primitive that is correct once, in the binding, is worth far more than the same code re-derived in every application that needs a background render
Why the callbacks are method pointers
The worker and reply are not anonymous methods. They are procedure of object types, TPdfFutureWorker<T> and TPdfFutureReply<T>, and that choice is forced by the compiler matrix. PDFiumPas compiles on Delphi XE5 and later and on Free Pascal 3.2 in Delphi mode, and FPC 3.2 in that mode does not support anonymous methods. A reference-to-procedure callback that captures local variables would compile on Delphi and fail on FPC, so the unit uses the lowest common denominator that both compilers accept
The practical consequence is where state lives. An anonymous method closes over locals; a method pointer does not. So any state the worker needs, the page index, the zoom, the output path, and any state the reply needs to update, the target image control or progress label, has to hang off the object whose method is being passed. In a viewer that object is usually the form or a render controller it owns. This is not a workaround imposed grudgingly; it keeps the ownership of that state explicit and visible on the receiving object instead of hidden inside a closure
Cooperative cancellation, not a hard kill
Cancellation here is cooperative. There is no API that reaches into the worker thread and terminates it, because terminating a thread mid-render leaves PDFium holding locks and partially written bitmaps, and the process state after a forced kill is not something you can reason about. Instead the worker is handed a read-only token and is expected to check it, and the render loop is written to check it between pages or between tiles, where stopping is clean
The token offers three ways to observe cancellation. IsCancelled is a cheap boolean poll for a loop that wants to test and decide for itself. ThrowIfCancelled is the common case: call it at a natural cancellation point and, if cancellation has been requested, it raises EPdfOperationCancelled, which unwinds the worker straight back to the future. RegisterCallback attaches a one-shot notification that fires once when the source is cancelled, useful when a worker is blocked in something it can interrupt rather than sitting in a tight loop
The exception is where the thread boundary matters. When the worker raises EPdfOperationCancelled, the future catches it and turns it into a cancelled status, so the reply sees IsCancelled and not a failure. The exception object itself is never marshaled to the main thread. It lives and dies on the worker thread; only its message string is copied into ErrorMessage. Marshaling a live exception object across threads would mean reaching into memory owned by a thread that is finishing, which is the same class of mistake the Synchronize fix exists to prevent. A status code and a string cross the boundary cleanly; an object would not
Two interfaces, so a worker cannot cancel itself
Cancellation is split across two interfaces on purpose. IPdfCancellationTokenSource is the write side: it has Cancel, and the owner that creates it, usually the form, keeps it and calls Cancel when the user clicks the button or the form closes. IPdfCancellationToken is the read side: it has IsCancelled, ThrowIfCancelled, and RegisterCallback, and that is all the worker ever receives. One concrete object implements both, but the worker is only ever handed the token, so it has no way to cancel the operation it is running. The split is an API-level guard rail. A worker that could reach Cancel through its token would invite a confused piece of code to cancel itself, and the type system removes the possibility
There is a matching detail for the case where a caller wants a render but never intends to cancel it. Rather than force a fresh source per call, the unit exposes PdfNoCancellationToken, a singleton token that is permanently in the not-cancelled state. Run substitutes it when the token argument is left nil. That singleton is constructed eagerly during unit initialization rather than lazily on first use, and the reason is concurrency again. If several Run calls on different worker threads all reached for a lazily created singleton at once, they could race on its construction, leak a duplicate, or briefly observe a half-initialised instance. Building it before any worker can run removes the race entirely
Running a cancellable render
In practice you create a source, keep it on the form, pass its Token into Run alongside a worker method and a reply method, and wire the Cancel button to the source. The worker checks the token while it renders; the reply updates the UI once the result is back. Because the callbacks are method pointers, the worker and reply read whatever they need from the form's fields
procedure TMainForm.StartRender;
begin
FCancelSource := TPdfCancellationTokenSource.New; // field, lives on the form
TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;
procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
if Assigned(FCancelSource) then
FCancelSource.Cancel; // worker observes this at its next cancel point
end;
// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
PageIndex: Integer;
begin
for PageIndex := FFirstPage to FLastPage do
begin
AToken.ThrowIfCancelled; // clean stop between pages
RenderOnePage(PageIndex); // synchronous PDFium rasterisation
end;
Result := True;
end;
// Runs on the main thread. Safe to touch the VCL here.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
if AResult.IsSuccess then
StatusLabel.Caption := 'Render complete'
else if AResult.IsCancelled then
StatusLabel.Caption := 'Cancelled'
else
StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;
The reply handles all three outcomes because all three are reachable. A finished render reports success, a user who pressed Cancel sees the cancelled branch, and a file that could not be written or a page that failed to parse arrives as a failure with a message. None of those branches block, none of them touch the worker thread, and the bitmap or status the worker produced is only read after the future has delivered it on the thread that owns the UI
The same threading discipline pays off elsewhere in a viewer. The way rendered bitmaps are kept and reused across zoom changes is covered in our note on the render cache and zoom performance, and the broader question of keeping the PDFium boundary safe under Delphi is in hardening the PDFium VCL ABI for memory safety. The async infrastructure described here ships as part of the PDFium Component for Delphi and C++Builder, alongside the rendering, text, and form APIs covered elsewhere on this blog