DEV Community

IronSoftware
IronSoftware

Posted on

QuestPDF Memory Leak Fix: Why Memory Grows in Production

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)
Enter fullscreen mode Exit fullscreen mode

In AWS CloudWatch or similar monitoring:

Container killed: OOMKilled
Memory limit exceeded: 2048MB used, 2048MB limit
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

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>";
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points about this code:

  • The using statement 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

  1. QuestPDF GitHub Issue #958{:rel="nofollow"} - Memory not released in production
  2. QuestPDF GitHub Issue #968{:rel="nofollow"} - Related memory issue

For the latest IronPDF documentation and tutorials, visit ironpdf.com.

Top comments (0)