Mail Merge Performance
How long does a merge take, and what makes it longer? Text Control has measured MailMerge with typical invoice templates, and the numbers show which parts of a template cost time and how far one thread goes. The same measurements lead to three practices for Windows Forms applications: design templates with the costs in mind, merge in the background with visible progress, and move large batches into separate processes.
What Text Control Measured
Text Control's MailMerge benchmark of 2023 merged an invoice template with a header, an SVG logo, an address and a merge block of line items, and saved the result as PDF. It ran as a .NET console application with ServerTextControl and MailMerge of TX Text Control .NET Server 31.0 SP2, on one thread, on an AMD Ryzen 7 2700X eight-core processor with 3.70 GHz and 16 GB of memory:
| Scenario | Result |
|---|---|
| One invoice of one page with 5 rows in the merge block | 0.35 seconds; the time grows linearly with the number of invoices, 80 invoices took about 16 seconds |
| One invoice of six pages with 100 rows in the merge block | 3.2 seconds, again growing linearly with the number of invoices |
| One merge block with a growing number of rows | 0.23 seconds for one row, growing faster than the number of rows up to 1,280 rows on 59 pages |
| The same rows split into two merge blocks | Faster than the same number of rows in one block |
| Two formulas in the merge block | Acceptable for short tables; the formulas are calculated again for every row |
| Encryption, digital signatures and embedded files in the PDF | No significant extra time; embedded files depend on the size of the file |
These numbers were measured with the server product and its hardware; they show proportions, not what your application will reach. The blog article MailMerge Class Performance Benchmark contains the charts and the code.
Design Templates With the Costs in Mind
- Keep long tables in several merge blocks. A single merge block took disproportionately longer as its rows grew, and the same rows in two blocks merged faster. When a report lists thousands of items, group them, for example by month or category, into separate blocks. Merge Blocks describes how blocks are built.
- Calculate long columns in the data. Formulas in a merge block are calculated for every row. For a table with many rows, pass totals and line amounts in the data instead of calculating them in the template.
- Create separate documents when you need them. With
appendset tofalse, each record becomes a document of its own, which theDataRowMergedevent delivers, as Create One Document or One per Record shows.
Merge in the Background With Progress and Cancel
A MailMerge whose TextComponent is the TextControl of the form merges on the UI thread, and the form doesn't respond until the merge ends. Connected to a ServerTextControl, it can merge on a background thread. Its DataRowMerged event occurs after each record, which makes it the place to report progress and to cancel.
The following handlers in Form1.cs merge all records of invoice.json into the template invoice.docx, move a ProgressBar named progressBar1, stop when the user clicks cancelMergeButton and show the result in textControl1:
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using TXTextControl;
using TXTextControl.DocumentServer;
private CancellationTokenSource mergeCancellation;
private async void mergeButton_Click(object sender, EventArgs e)
{
string json = File.ReadAllText("invoice.json");
using (JsonDocument data = JsonDocument.Parse(json))
{
progressBar1.Maximum = data.RootElement.GetArrayLength();
}
progressBar1.Value = 0;
mergeButton.Enabled = false;
IProgress<int> progress = new Progress<int>(count => progressBar1.Value = count);
mergeCancellation = new CancellationTokenSource();
CancellationToken token = mergeCancellation.Token;
try
{
byte[] result = await Task.Run(() =>
{
using (ServerTextControl tx = new ServerTextControl())
{
tx.Create();
LoadSettings loadSettings = new LoadSettings
{
ApplicationFieldFormat = ApplicationFieldFormat.MSWord,
LoadSubTextParts = true
};
tx.Load("invoice.docx", StreamType.WordprocessingML, loadSettings);
using (MailMerge mailMerge = new MailMerge { TextComponent = tx })
{
int merged = 0;
// Runs on this background thread after each record
mailMerge.DataRowMerged += (s, args) =>
{
progress.Report(++merged);
args.Cancel = token.IsCancellationRequested;
};
mailMerge.MergeJsonData(json);
}
tx.Save(out byte[] document, BinaryStreamType.InternalUnicodeFormat);
return document;
}
});
textControl1.Load(result, BinaryStreamType.InternalUnicodeFormat);
}
catch (MergeCanceledException)
{
MessageBox.Show(this, "The merge was canceled.");
}
finally
{
mergeButton.Enabled = true;
mergeCancellation.Dispose();
mergeCancellation = null;
}
}
private void cancelMergeButton_Click(object sender, EventArgs e)
{
mergeCancellation?.Cancel();
}
The code works as follows:
- The template is loaded with
LoadSettingsthat import the MS Word fields and the merge blocks of the DOCX file, as in Merge Data into Fields. MergeJsonData merges all records into one document. - The
DataRowMergedhandler runs on the thread that merges, not on the UI thread. It doesn't touch the form:Progress<int>passes the count to the UI thread. - Setting Cancel of the event arguments to
trueends the merge with a MergeCanceledException, whichawaitthrows again on the UI thread. - The merged document returns as a byte array and is loaded into the editor on the UI thread.
For long merges, MailMerge.MergeCulture matters too: it defaults to the culture of the current thread, which on a background thread may not be the one of your form. UI Threading and Background Operations explains why.
Merge Large Batches in Separate Processes
Threads that merge in the same process can wait for each other: TX Text Control guards resources that all its instances in a process share with critical sections. For merges of hundreds or thousands of documents, Text Control therefore recommends several processes instead of several threads. In its sample of 2019, a host application passed each template and its data to a new process of a processing application, which merged it with ServerTextControl and MailMerge and returned the PDF through anonymous pipes. Merging 100 templates took about 45 seconds with normal processing and 14 seconds with parallel processes on a CPU with 16 cores. The sample is a Windows Forms application; the blog article True Parallel Processing of Documents using MailMerge describes it and its code.
Text Control also publishes the NuGet package TXTextControl.DocumentServer.ParallelMailMerge, which runs MailMerge jobs in a pool of worker processes that your application starts. It names Windows Forms applications among the project types it can be used in, but it targets .NET 10 and depends on TXTextControl.TextControl.Core.SDK, the package of TX Text Control .NET Server, not on TXTextControl.TextControl.WinForms.SDK. Multiple Processes in the documentation of TX Text Control .NET Server describes the package, its options and its measurements.
Next Steps
- Overview — where a merge runs and how its results are created.
- Merge Events — all events of
MailMerge. - UI Threading and Background Operations — threads, progress and cancellation in Windows Forms.
- Performance Best Practices — all measures at a glance.