Analysis of PDF & ClosedXML Excel Optimizations
This document details the analysis and recommendations for addressing performance, memory, and thread-pool starvation issues across the PDF and Excel generation components of ErpCrystal_MFG.
1. Synchronous .Result & I/O Calls in QuestPDF Compose()
Take
We confirmed the presence of synchronous .Result calls and direct synchronous database operations inside the Compose() methods across multiple QuestPDF documents.
Findings
QuestPDF_ConfirmationOfBalance.cs(line 35): Calls.ResultonFinancialReportData(...)fromIFinancialReportRepository.QuestPDF_PrintLabel.cs(lines 31, 45, 52): Calls.Resulton async methodsPrintLabelSubTrnDetails(...)andGetTrnIdList(...).QuestPDF_Payslip.cs(lines 39, 44): Calls.ResultonGetEmployeeListData(...)andPrintPayslipData(...).QuestPDF_ChequePrint.cs(lines 117, 131, 141, 148, 157, 173): Calls.Resulton multiple configuration fetching methods (GetLabelAcPayeePaddingInfo, etc.) inside the layout-rendering helpersComposeHeaderandComposeDateContent.QuestPDF_Voucher.cs(lines 30, 39, 40): Does not call.Resultbecause the repository methods are synchronous interfaces, but it still performs direct database access synchronously insideCompose().
Technical Impact
QuestPDF executes Compose() synchronously during the document structure compilation and rendering phase.
- Thread-Pool Starvation: Performing blocking
.Resultor synchronous database I/O calls blocks the activeThreadPoolthread. Under concurrent load, this triggers thread-pool starvation, causing significant API latency spike or timeouts. - Redundant Database Queries: QuestPDF may evaluate layout delegates (like headers, footers, or table structures) multiple times to calculate pagination and layout containment. If data fetching is inside these methods, the same database queries are executed repeatedly for a single PDF.
Proposed Solution
- Rule:
Compose()and its descendants should only execute layout logic and reference pre-loaded in-memory models. No I/O, database access, or.Resultshould happen inside them. - Implementation: In the calling API Controller (e.g.,
FinancialReportController.cs,QrPrintController.cs,VoucherController.cs), fetch all required data asynchronously usingawaitbefore instantiating the QuestPDF document. Pass the fully populated data models into the QuestPDF document’s constructor.
2. QuestPDF_ConfirmationOfBalance.cs Party Loop and State Mutation
Take
The loop structure in QuestPDF_ConfirmationOfBalance.cs is both a performance bottleneck and a serious concurrency/correctness hazard. We also confirmed that the using QRCoder; import is completely unused.
Findings
- Class-level Field Mutation:
The loop mutates these class-level fields inside
PartyDetails = party; PartyListData = ConfirmationOfBalanceData.Where(...).ToList();Compose(), while the page delegates (ComposeHeader,ComposeContent,ComposeFooter) reference them. Because QuestPDF layout compilation happens in multiple steps, referencing mutated instance fields is a bug hazard that can cause pages to display data from the wrong party. - Unused Imports:
using QRCoder;(line 13) is imported but never used. - Image Path: The logo image path is correctly resolved using
_logoImagePath + "/erplogo.png".
Proposed Solution
- State Isolation (Closures): Eliminate class-level state fields for party details. Capture party data in local block-scoped variables inside
Compose()and pass them explicitly to content-generation methods using lambda closures:foreach (var party in PartyList) { var localParty = party; var localPartyData = ConfirmationOfBalanceData.Where(d => d.SubAcCode == party.SubAcCode && d.DocType != "a").ToList(); container.Page(page => { page.Header().Element(c => ComposeHeader(c, localParty)); page.Content().Element(c => ComposeContent(c, localParty, localPartyData)); page.Footer().Element(c => ComposeFooter(c, localParty)); }); } - Import Cleanup: Remove
using QRCoder;.
3. QuestPDF_PrintLabel.cs QR Code Disposal Validation
Take
The QR disposal concern is indeed invalid. No fix is required here.
Findings
The code in QuestPDF_PrintLabel.cs (lines 70-80) uses C# 8.0 using var declarations:
using var qrGenerator = new QRCodeGenerator();
using var qrCodeData = qrGenerator.CreateQrCode(...);
using var qrCode = new PngByteQRCode(qrCodeData);using var ensures that Dispose() is implicitly called on these objects when the enclosing method GenerateQrCodeForLabel returns. There are no leaked unmanaged or native resources here.
4. ClosedXML AdjustToContents Optimization
Take
We must prioritize replacing AdjustToContents with predefined column widths. This is a critical CPU and memory optimization for large exports.
Findings
AdjustToContentsis called 92 times across the codebase.- In
GstReportController.cs(lines 181, 285, 350), it is executed repeatedly for multiple worksheets in the same export flow (e.g.,workSheet.Columns().AdjustToContents(6);andworkSheet1.Columns().AdjustToContents(5);). - ItemMstController.cs has 30 separate calls to
AdjustToContents().
Technical Impact
AdjustToContents() forces ClosedXML to evaluate the width of every string value in the sheet against font metrics. This requires loading graphic contexts (GDI+ or Skia) and creates high memory churn, which grows linearly with row counts.
Proposed Solution
We should prioritize refactoring the heavy exporters (ItemMst, CrewMst, GstReport, AccountMst, PurchaseAnalysis) to use fixed column widths for standard data fields:
- Standard widths:
- GSTIN / PAN: Width =
16 - Document No / Code: Width =
15 - Dates: Width =
12 - Amounts / Quantities: Width =
15 - Names / Particulars: Width =
45(withStyle.Alignment.WrapText = true)
- GSTIN / PAN: Width =
- This will yield a 5x to 10x speedup on file generation and eliminate the memory overhead associated with auto-fit measurement.
5. Eager Image Byte Preloading in QuestPDF Documents
Take
Preloading image bytes via File.ReadAllBytes in constructors is a significant memory sink and should be cleaned up across all QuestPDF documents.
Findings
QuestPDF_Voucher.cs does this on line 26:
private readonly byte[]? _headerImgBytes = !string.IsNullOrEmpty(_voucherprint.HeaderImg) && File.Exists(_voucherprint.HeaderImg) ? File.ReadAllBytes(_voucherprint.HeaderImg) : null;A codebase-wide search reveals that 10 different QuestPDF documents follow this exact pattern:
QuestPDF_Dncn.csQuestPDF_Grn.csQuestPDF_Indent.csQuestPDF_Invoice.csQuestPDF_JV.csQuestPDF_JWMiscellaneous.csQuestPDF_JobWork.csQuestPDF_Payslip.csQuestPDF_SalesOrder.csQuestPDF_Voucher.cs
Technical Impact
Eagerly loading files into byte arrays during construction adds synchronous disk I/O to the instantiation flow and keeps large byte arrays in the managed heap.
Proposed Solution
QuestPDF has native support for image path strings (.Image(string filePath)).
We should update the image container rendering logic to check if the file exists and pass the path directly:
if (!string.IsNullOrEmpty(Model.HeaderImg) && File.Exists(Model.HeaderImg))
{
column.Item().Image(Model.HeaderImg);
}This delegates reading to QuestPDF’s internal renderer, saving memory and eliminating early file-read overhead.