Performance & Scale

Caching Strategies for Fast Dashboards at Scale

Metabase's caching layer stores the results of executed queries so that subsequent requests for the same data are served from cache rather than re-que...

πŸ“…
πŸ“–9 min read

Caching Strategies for Fast Dashboards at Scale

Metabase's caching layer stores the results of executed queries so that subsequent requests for the same data are served from cache rather than re-querying the database. For dashboards where data doesn't need to be real-time β€” most analytics use cases β€” caching is the single highest-leverage performance optimization available. A dashboard that takes 8 seconds to load against a live database can load in under 500 milliseconds from cache.

---

How Metabase Caching Works

When caching is enabled and a question is executed, Metabase stores the query results in its cache. The next time the same question is requested (same filters, same parameters), Metabase returns the cached result without hitting the database. Results stay in cache until either:

  • The cache TTL (time-to-live) expires
  • A manual cache invalidation is triggered
  • The question's query definition changes
  • The cache stores results per question per parameter combination. A dashboard filtered to organization_id=99 and organization_id=100 are cached separately.

    What Gets Cached

  • Query builder questions
  • Native SQL questions
  • Dashboard question results (when the dashboard loads)
  • Individually executed questions
  • What Doesn't Get Cached

  • Questions with parameters that change frequently (effectively every request has a unique parameter set)
  • Results from questions executed via the API with ignore_cache=true
  • Admin-level queries and diagnostic queries
  • ---

    Caching Configuration

    Enabling Caching

    Navigate to Admin β†’ Performance β†’ Caching.

    Minimum query duration: Metabase only caches questions that took at least this long to execute. Default is 60 seconds β€” reduce to 1–5 seconds to cache most analytical queries:

    Minimum query duration to cache: 5 seconds

    Setting this too low (e.g., 0) caches everything including trivial fast queries, which wastes cache storage. Setting it too high means slow queries don't get cached at all.

    Cache TTL multiplier: Controls how long results are cached relative to how long the query took to run. Default is 10 β€” a query that takes 10 seconds gets cached for 100 seconds (10 Γ— 10). Increase for dashboards where data changes slowly:

    TTL multiplier: 10 (default)

    A query taking 30 seconds with a TTL multiplier of 10 caches for 300 seconds (5 minutes). With a multiplier of 100, it caches for 3,000 seconds (50 minutes).

    Max cache entry size: Maximum size of a single cached result set. Default is 1MB. Increase if you have large result sets that exceed this limit:

    Max cache entry size: 5 MB

    Cache Storage Backend

    By default, Metabase stores cached results in its application database (PostgreSQL). This is simple but adds load to the application database for cache reads/writes.

    For high-traffic deployments, configure an external cache backend. Metabase Pro and Enterprise support Redis:

    bash
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">Environment variables for Redis cache</h1> MB_REDIS_HOST=your-redis-host MB_REDIS_PORT=6379 MB_REDIS_PASSWORD=your-redis-password # if auth required MB_REDIS_SSL=true # if TLS required

    Redis configuration recommendations:

    maxmemory: 2gb                    # size based on expected cache volume
    

    maxmemory-policy: allkeys-lru # evict least recently used keys when full

    With Redis, cache reads/writes are offloaded from your PostgreSQL application database, and cache can be shared across multiple Metabase processes (relevant if you ever run more than one instance).

    ---

    Per-Dashboard and Per-Question Cache Settings

    Metabase Pro and Enterprise allow setting cache TTL per question and per dashboard, overriding the global default:

    Question-Level Caching

    For a specific question, set a custom cache TTL in Question β†’ ... β†’ Edit β†’ Caching:

    Dashboard typeRecommended TTL
    Real-time operational metricsNo cache or 1–5 minutes
    Daily business metrics1–4 hours
    Weekly / monthly reports24 hours
    Historical analysis (never changes)7 days or indefinite

    Dashboard-Level Caching (Enterprise)

    Set a default TTL for all questions on a specific dashboard:

    Dashboard: Customer Analytics Portal
    

    Cache TTL: 60 minutes Reasoning: Customer data updates hourly from ETL; caching for the full hour prevents NΓ—customers queries every time the portal is loaded

    ---

    Cache Warming

    Cache warming pre-populates the cache before users request data, ensuring the first load is fast. Without cache warming, the first user to load a dashboard after cache expiry hits the full query cost.

    Manual Cache Warming via API

    Trigger question execution (which populates the cache) on a schedule:

    javascript
    

    // cache-warmer.js β€” run as a scheduled job every 30 minutes async function warmCache(questionIds) { console.log(Warming cache for ${questionIds.length} questions...);

    for (const questionId of questionIds) { try { const res = await fetch( ${process.env.METABASE_SITE_URL}/api/card/${questionId}/query, { method: "POST", headers: { "x-api-key": process.env.METABASE_API_KEY, "Content-Type": "application/json", }, body: JSON.stringify({ parameters: [] }), } );

    if (res.ok) { console.log( βœ“ Question ${questionId} cached); } else { console.error( βœ— Question ${questionId} failed: ${res.status}); }

    // Small delay to avoid overwhelming the database await new Promise((r) => setTimeout(r, 500)); } catch (err) { console.error( βœ— Question ${questionId} error: ${err.message}); } } }

    // Questions to warm β€” update this list as dashboards evolve const QUESTIONS_TO_WARM = [42, 43, 44, 51, 52, 53, 61]; warmCache(QUESTIONS_TO_WARM);

    Kubernetes CronJob for Cache Warming

    yaml
    

    <h1 class="text-4xl font-bold mb-6 text-slate-900">cache-warmer-cronjob.yaml</h1> apiVersion: batch/v1 kind: CronJob metadata: name: metabase-cache-warmer namespace: metabase spec: schedule: "<em class="italic">/30 </em> <em class="italic"> </em> *" # every 30 minutes jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: cache-warmer image: node:20-alpine command: - node - /scripts/cache-warmer.js env: - name: METABASE_SITE_URL value: "http://metabase.metabase.svc.cluster.local:3000" - name: METABASE_API_KEY valueFrom: secretKeyRef: name: metabase-secrets key: CACHE_WARMER_API_KEY

    Multi-Tenant Cache Warming

    For embedded analytics serving many tenants, cache warming becomes per-tenant β€” you need to warm the cache for each customer's parameter combination:

    javascript
    

    async function warmMultiTenantCache(tenants, dashboardQuestionIds) { for (const tenant of tenants) { for (const questionId of dashboardQuestionIds) { await fetch( ${METABASE_URL}/api/card/${questionId}/query, { method: "POST", headers: { "x-api-key": API_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ parameters: [ { type: "category", target: ["variable", ["template-tag", "organization_id"]], value: String(tenant.organizationId), }, ], }), } ); await delay(200); // rate limit } } }

    This is most practical for a moderate number of tenants (< 500). For thousands of tenants, focus on warming the most active tenants and rely on database-level caching for others.

    ---

    Combining Metabase Caching with Database-Level Caching

    Metabase caching and database-level caching are complementary:

    Request β†’ Metabase cache (hit: <100ms)
    

    β”‚ └─ miss β†’ Database query executor β”‚ β”œβ”€ Snowflake result cache (hit: ~200ms) β”œβ”€ PostgreSQL shared buffers (hit: ~10ms) └─ Disk / S3 (miss: slow)

    Snowflake Result Cache

    Snowflake automatically caches query results for 24 hours. Identical queries (same SQL, same database state) return from cache without consuming compute credits. This is transparent to Metabase β€” the query executes instantly from Snowflake's perspective.

    To maximize Snowflake result cache hits:

  • Keep Metabase's generated SQL deterministic (avoid NOW() in queries β€” use date filters instead)
  • Use the same virtual warehouse for the same question (different warehouses have separate caches)
  • PostgreSQL Shared Buffers

    PostgreSQL's shared buffer pool caches frequently-accessed data pages in memory. Ensure your PostgreSQL instance has enough RAM allocated to shared buffers:

    sql
    

    -- Check current shared_buffers setting SHOW shared_buffers;

    -- Recommended: 25% of available RAM -- For a 16GB instance: 4GB -- Set in postgresql.conf or RDS parameter group: -- shared_buffers = 4GB

    Metabase's repeated queries to the same tables will increasingly hit shared buffers as the buffer pool warms up.

    ---

    Cache Invalidation

    Automatic Invalidation

    Metabase automatically invalidates cached results when:

  • A question's query definition changes
  • The cache TTL expires
  • A manual invalidation is triggered
  • Manual Cache Invalidation via API

    javascript
    

    // Invalidate cache for a specific question async function invalidateQuestionCache(questionId) { await fetch( ${METABASE_SITE_URL}/api/card/${questionId}/query/cache, { method: "DELETE", headers: { "x-api-key": API_KEY }, } ); }

    // Invalidate cache for all questions in a dashboard async function invalidateDashboardCache(dashboardId) { const dashboard = await fetch( ${METABASE_SITE_URL}/api/dashboard/${dashboardId}, { headers: { "x-api-key": API_KEY } } ).then(r => r.json());

    for (const card of dashboard.dashcards) { if (card.card_id) { await invalidateQuestionCache(card.card_id); } } }

    Event-Driven Cache Invalidation

    For dashboards where users expect data to be fresh immediately after an action (e.g., they just placed an order and want to see it in their analytics), invalidate the cache after the triggering event:

    javascript
    

    // In your order creation handler async function createOrder(orderData) { const order = await db.orders.create(orderData);

    // Invalidate analytics cache for this customer await invalidateDashboardCache(CUSTOMER_ANALYTICS_DASHBOARD_ID);

    return order; }

    This ensures the next dashboard load shows fresh data. Without invalidation, the user would see stale cached data until the TTL expires.

    ---

    Measuring Cache Effectiveness

    Monitor cache hit rates to understand how well caching is working:

    sql
    

    -- Query cache hit statistics from the Metabase application database SELECT card_id, COUNT(*) as total_executions, SUM(CASE WHEN cache_hit THEN 1 ELSE 0 END) as cache_hits, ROUND( 100.0 <em class="italic"> SUM(CASE WHEN cache_hit THEN 1 ELSE 0 END) / COUNT(</em>), 1 ) as hit_rate_pct, AVG(running_time) as avg_runtime_ms FROM query_execution WHERE started_at > NOW() - INTERVAL '7 days' GROUP BY card_id ORDER BY total_executions DESC LIMIT 20;

    A healthy cache hit rate for a dashboard-focused deployment is 70–90%. Low hit rates suggest the TTL is too short, parameter combinations are too varied, or the minimum query duration threshold is too high.

    ---

    Caching Anti-Patterns

    Caching real-time operational metrics Don't cache dashboards where users need immediate accuracy β€” fraud monitoring, live order status, incident response dashboards. For these, disable caching for the specific questions or set TTL to 0.

    Caching with highly variable parameters If every request uses a unique parameter (e.g., a specific user ID that changes every time), each parameter combination is cached separately but never reused. This wastes cache storage without improving performance. For highly variable parameters, consider pre-aggregating at the user level.

    Setting TTL longer than your data refresh frequency If your ETL runs every hour, setting a 24-hour cache TTL means users see data that's potentially 25 hours old. Match cache TTL to your data refresh schedule.

    Not warming the cache A cold cache means the first user after TTL expiry experiences full query latency. For customer-facing dashboards, cache warming is not optional.

    ---

    Summary

    Metabase caching stores query results in its application database or Redis, serving subsequent requests without hitting the database. Configure the minimum query duration (lower it to 5 seconds to cache most analytical queries), TTL multiplier, and max cache size in Admin β†’ Performance. Use per-question and per-dashboard TTL overrides to match cache duration to data freshness requirements. Warm the cache proactively via the API to ensure the first load after expiry is fast. For multi-tenant embedded analytics, warm per-tenant parameter combinations for your most active customers. Measure cache hit rates from the query_execution table and target 70–90% for dashboard-focused deployments.