Developers using QuestPDF in production environments report memory that accumulates with each PDF generation and never returns to baseline. The issue manifests most prominently on Linux servers and cloud platforms like AWS, where memory consumption grows steadily until service restarts become necessary. This article documents the community's experience and examines an alternative approach that handles memory more predictably.
The Problem
QuestPDF allocates memory during document generation that is not properly released between operations. In development environments with Workstation garbage collection, this may not be immediately apparent. However, in production environments using Server garbage collection, memory growth becomes pronounced after generating multiple documents.
The issue is particularly problematic for:
- Long-running web services generating reports on demand
- Batch processing systems creating multiple PDFs sequentially
- Cloud-hosted applications where memory limits trigger container restarts
- Background workers processing document queues
Each PDF generation cycle adds to the accumulated memory, and standard .NET garbage collection does not reclaim it. Even forcing garbage collection with GC.Collect() does not resolve the issue, as the memory appears to be held by native resources in QuestPDF's SkiaSharp backend.
Error Messages and Symptoms
The issue typically manifests through infrastructure monitoring rather than explicit error messages:
Application memory usage: 512MB → 1.2GB → 2.4GB → 4.8GB (crash)
In AWS CloudWatch or similar monitoring:
Container killed: OOMKilled
Memory limit exceeded: 2048MB used, 2048MB limit
Symptoms include:
- Memory baseline increasing after each PDF generation
- Application performance degrading as memory pressure increases
- Container restarts in Kubernetes/ECS/Docker environments
- Memory profilers showing unreleased byte arrays and image data
Who Is Affected
This issue impacts production deployments generating multiple PDFs over time:
Operating Systems: Amazon Linux 2023, Ubuntu, Debian, and other Linux distributions commonly used in cloud environments. The issue also occurs on Windows but may be masked by different garbage collection behavior.
Framework Versions: .NET 6, .NET 7, and .NET 8. Reports span QuestPDF versions 2024.6.0 through 2024.10.2.
Use Cases: Report generation services, invoice systems, document automation pipelines, and any application that generates PDFs repeatedly rather than as one-time operations.
Environments: AWS ECS, Azure App Service, Kubernetes, Docker containers, and any production infrastructure with memory limits.
Evidence from the Developer Community
Timeline
| Date | Event | Source |
|---|---|---|
| 2024-07-20 | Memory not released in production reported | GitHub Issue #958 |
| 2024-07-22 | Additional users confirm similar behavior | GitHub Issue #958 |
| 2024-08-15 | Related issue #968 linked | GitHub Issue #958 |
| 2024-10-26 | Issue persists through version 2024.10.2 | GitHub Issue #958 |
Community Reports
"I'm afraid I'm going to run out of memory. Production environment: Amazon Linux 2023 (t3.medium). Each PDF generation seems to add memory that never gets released."
— Developer, GitHub Issue #958, July 2024"I was experiencing similar issues with memory leaks and 2024.x versions. The memory profiler shows byte arrays accumulating that aren't being collected."
— Developer, GitHub Issue #958, September 2024"Around 180 jpeg files go into the PDF, maybe 500MB of image data. After generation, that memory should be released but it persists until the service restarts."
— Developer, GitHub Issue #958, July 2024
Root Cause Analysis
QuestPDF uses SkiaSharp for rendering, which involves native memory allocation outside the .NET managed heap. When processing images and generating PDF content, memory is allocated in the native layer. This memory is not tracked by the .NET garbage collector in the same way as managed objects.
Several factors contribute to the issue:
Server Garbage Collection: Production environments typically use Server GC, which behaves differently from Workstation GC. It may hold onto memory longer, waiting for memory pressure before collecting.
Native Resource Management: SkiaSharp objects that wrap native handles may not be disposed promptly. Even when .NET objects are garbage collected, the native memory may remain allocated.
Image Processing: When PDFs contain many images (as in the reported case with 180 JPEG files), the memory footprint grows significantly. Image byte arrays loaded for embedding persist longer than expected.
Companion App Architecture: QuestPDF's debugging features use a companion application architecture that may maintain references to document data.
How to Monitor Memory in .NET PDF Generation
Before attempting fixes, developers should establish baseline metrics and confirm the memory leak pattern. The following diagnostic approaches help identify the scope of the problem.
Using dotnet-counters for Runtime Monitoring
# Install the diagnostics tool
dotnet tool install --global dotnet-counters
# Monitor memory in real-time for your application
dotnet-counters monitor --process-id <PID> System.Runtime
# Key metrics to watch:
# - GC Heap Size (MB) - should stabilize between generations
# - Gen 2 Size (MB) - growth here indicates long-lived objects
# - LOH Size (MB) - large object heap, common source of fragmentation
Memory Profiling Code Pattern
public class MemoryDiagnostics
{
public static void LogMemoryUsage(string checkpoint)
{
// Force collection to get accurate readings
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var process = Process.GetCurrentProcess();
var managedMemory = GC.GetTotalMemory(false) / 1024 / 1024;
var workingSet = process.WorkingSet64 / 1024 / 1024;
var privateBytes = process.PrivateMemorySize64 / 1024 / 1024;
Console.WriteLine($"[{checkpoint}] Managed: {managedMemory}MB, Working Set: {workingSet}MB, Private: {privateBytes}MB");
}
}
// Usage in PDF generation
public byte[] GeneratePdfWithDiagnostics(DocumentModel model)
{
MemoryDiagnostics.LogMemoryUsage("Before PDF generation");
var document = CreateDocument(model);
byte[] result = document.GeneratePdf();
MemoryDiagnostics.LogMemoryUsage("After PDF generation");
return result;
}
Typical Memory Leak Pattern
When monitoring QuestPDF memory usage over multiple generations, a typical pattern emerges:
| Generation | Before (MB) | After (MB) | Delta |
|---|---|---|---|
| 1 | 150 | 280 | +130 |
| 2 | 280 | 410 | +130 |
| 3 | 410 | 540 | +130 |
| 10 | 1300 | 1430 | +130 |
| 20 | 2600 | 2730 | +130 |
The consistent delta without return to baseline indicates memory that is not being reclaimed.
Attempted Workarounds
Workaround 1: Force Garbage Collection
Approach: Call GC.Collect() and GC.WaitForPendingFinalizers() after each PDF generation.
public byte[] GeneratePdf(DocumentModel model)
{
var document = CreateDocument(model);
byte[] result = document.GeneratePdf();
// Attempt to force cleanup
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
return result;
}
Limitations:
- Does not reclaim native memory held by SkiaSharp
- Adds performance overhead
- Not recommended as a standard practice in .NET applications
Workaround 2: Scheduled Service Restarts
Approach: Configure container orchestration to restart services periodically.
# Kubernetes CronJob example
spec:
schedule: "0 */4 * * *" # Every 4 hours
jobTemplate:
spec:
template:
spec:
containers:
- name: pdf-service
command: ["kubectl", "rollout", "restart", "deployment/pdf-service"]
Limitations:
- Causes service interruption
- Not viable for real-time systems
- Masks the problem rather than solving it
Workaround 3: Downgrade to Earlier Version
Approach: Use QuestPDF version 2024.6.x or earlier where the issue may be less pronounced.
Limitations:
- Loses access to newer features and fixes
- Not a permanent solution
- May introduce other compatibility issues
Workaround 4: Docker Memory Limits and Restart Policies
Approach: Configure Docker containers with memory limits and automatic restart on OOM.
# Dockerfile with memory considerations
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
# Environment variables to help with memory
ENV DOTNET_GCHeapHardLimit=0x40000000
ENV DOTNET_GCHeapHardLimitPercent=0x50
COPY . .
ENTRYPOINT ["dotnet", "YourApp.dll"]
# docker-compose.yml with memory limits
version: '3.8'
services:
pdf-service:
image: your-pdf-service
deploy:
resources:
limits:
memory: 2G
reservations:
memory: 512M
restart: unless-stopped
# Container restarts when memory limit hit
Limitations:
- Treats symptoms, not the root cause
- Users may experience service interruption during restarts
- Requires capacity planning to handle restart frequency
Workaround 5: Kubernetes OOM Killer Prevention
Approach: Configure Kubernetes resources and probes to manage memory growth.
# kubernetes-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: pdf-generator
spec:
replicas: 2
template:
spec:
containers:
- name: pdf-service
image: your-pdf-service
resources:
requests:
memory: "512Mi"
limits:
memory: "2Gi"
env:
- name: DOTNET_GCHeapHardLimit
value: "1610612736" # 1.5GB in bytes
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
# Consider readiness probe that fails under memory pressure
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 5
// Health check that monitors memory
public class MemoryHealthCheck : IHealthCheck
{
private readonly long _memoryThresholdBytes = 1_800_000_000; // 1.8GB
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var allocated = GC.GetTotalMemory(false);
var workingSet = Process.GetCurrentProcess().WorkingSet64;
if (workingSet > _memoryThresholdBytes)
{
return Task.FromResult(HealthCheckResult.Unhealthy(
$"Memory pressure: {workingSet / 1024 / 1024}MB"));
}
return Task.FromResult(HealthCheckResult.Healthy(
$"Memory OK: {workingSet / 1024 / 1024}MB"));
}
}
Limitations:
- Complex infrastructure setup
- Still requires periodic pod restarts
- May impact SLA during rolling restarts
A Different Approach: IronPDF
IronPDF takes a different approach to PDF generation that avoids the native memory management issues seen with SkiaSharp-based libraries.
Why IronPDF Handles Memory Differently
IronPDF uses an embedded Chromium rendering engine rather than SkiaSharp. The Chromium engine runs as a managed subprocess with its own memory space. When document generation completes, the subprocess memory is cleanly released without relying on .NET garbage collection to track native resources.
Key architectural differences:
- Chromium subprocess isolation prevents memory leaks from affecting the main application
- Image processing happens in the rendering engine, not in .NET managed code
- No SkiaSharp dependency means no SkiaSharp memory management issues
Code Example
using IronPdf;
public class ReportGenerator
{
public byte[] GenerateReport(ReportData data)
{
// Configure the renderer
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.MarginBottom = 20;
// Generate HTML from your data
string html = BuildReportHtml(data);
// Render to PDF - memory is managed by Chromium subprocess
using var pdf = renderer.RenderHtmlAsPdf(html);
return pdf.BinaryData;
}
private string BuildReportHtml(ReportData data)
{
// Build your report HTML with embedded images
return $@"
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; }}
.report-header {{ text-align: center; }}
img {{ max-width: 100%; }}
</style>
</head>
<body>
<div class='report-header'>
<h1>{data.Title}</h1>
</div>
<div class='report-content'>
{data.Content}
</div>
</body>
</html>";
}
}
Key points about this code:
- The
usingstatement ensures proper disposal - Memory for images and content is managed by the Chromium process
- No accumulation of native memory between generation cycles
API Reference
For more details on the methods used:
Migration Considerations
Licensing
- IronPDF is commercial software with perpetual licensing options
- Free trial available for evaluation and development
- Licensing details
API Differences
- QuestPDF uses a fluent API for document layout; IronPDF uses HTML/CSS
- If you're already generating HTML for other purposes, IronPDF may require less code
- Complex layouts may need to be translated from QuestPDF's component model to HTML/CSS
What You Gain
- Predictable memory behavior in long-running services
- No SkiaSharp dependency or native memory management concerns
- Chromium-based rendering with full CSS3 and JavaScript support
What to Consider
- HTML/CSS approach differs from QuestPDF's programmatic layout
- Requires Chromium binaries (approximately 200MB)
- Commercial license required for production use
Conclusion
QuestPDF's memory retention issue in production environments stems from native resource management in its SkiaSharp rendering layer. For services generating PDFs continuously, this leads to memory growth that only resets with service restarts. IronPDF's subprocess-based architecture provides an alternative that handles memory cleanup more predictably.
Jacob Mellor is CTO at Iron Software with 25+ years building developer tools.
References
- QuestPDF GitHub Issue #958{:rel="nofollow"} - Memory not released in production
- QuestPDF GitHub Issue #968{:rel="nofollow"} - Related memory issue
For the latest IronPDF documentation and tutorials, visit ironpdf.com.
Top comments (0)