Stop Paying for Idle Databricks Apps: How to Scale Down When Nobody's Watching
by Doug M.
Databricks Apps is a genuinely great way to deploy interactive data applications — Streamlit dashboards, Gradio ML demos, custom Flask APIs — directly inside your Databricks workspace, with Unity Catalog access and OAuth baked in. No separate infrastructure, no ingress controllers, no Docker registries to manage.
But there’s a cost pattern that catches teams off guard: a Databricks App that nobody is actively using still consumes compute. If you deployed a dashboard for your weekly leadership review and nobody thought to scale it down, you’ve been paying for it every day since — including the six days between each meeting.
This post covers exactly how Databricks Apps compute works, what “idle” actually costs you, and every mechanism available to scale down or stop apps when they’re not needed.
How Databricks Apps Compute Works
Each Databricks App runs on a small dedicated compute instance — separate from your clusters and SQL warehouses. When you deploy an app, Databricks provisions this instance and it stays running until you explicitly stop it or configure it to scale down.
The key distinction from serverless SQL warehouses: Databricks Apps do not auto-stop by default based on inactivity. A SQL warehouse with auto-stop set to 10 minutes will shut itself off when queries stop. An app will keep running indefinitely, waiting for the next user to show up.
This makes sense for apps that need to be instantly available — there no one wants a 3-minute cold start when clicking a dashboard link. But for apps with predictable usage windows (business hours only, weekly reviews, ad-hoc demos), always-on compute is significant waste.
What Idle Apps Actually Cost
Databricks Apps run on the apps DBU type, billed at a lower rate than job or all-purpose compute — but the cost accumulates continuously when the app is running.
Before diving into the numbers, there’s an important billing nuance depending on your cloud provider:
Azure: Databricks (and the underlying Azure VM) bills on a per-hour basis. If your app runs for 10 minutes and you stop it, you’re still charged for a full hour. Stopping apps mid-hour on Azure doesn’t save you anything on that partial hour — the value comes from avoiding the next full hour from accruing. Align your stop schedules to clean hour boundaries (e.g. 6:00pm, not 6:23pm) to avoid paying for time you won’t use.
AWS / GCP: DBU usage is metered at per-second granularity, so you pay only for exact compute time consumed. Stopping an app after 10 minutes genuinely saves you 50 minutes of charges.
This affects how you design your scale-down strategy, not whether you should bother — the savings across avoided hours are substantial on all clouds.
A rough illustration for a single app running 24/7 vs. stopped outside business hours:
| Scenario | Hours/month running | Estimated monthly cost |
|---|---|---|
| Always-on (24/7) | 720 hrs | ~$50–$120 |
| Business hours only (8am–6pm, M–F) | 200 hrs | ~$14–$33 |
| Stopped when idle (AWS/GCP per-second) | Exact usage only | Pay per second of actual use |
| Potential savings | ~70%+ on all clouds |
Multiply that across five, ten, or twenty apps in a busy workspace and the idle cost becomes a meaningful line item — regardless of cloud.
Option 1: Manual Stop and Start
The simplest approach — and often overlooked — is just stopping the app when you know it won’t be used.
In the Databricks UI: Apps → your app → Stop
Via the Databricks CLI:
# Stop an app
databricks apps stop <app-name>
# Start it again when needed
databricks apps start <app-name>
Via the REST API:
# Stop
curl -X POST \
"https://<your-workspace>.azuredatabricks.net/api/2.0/apps/<app-name>/stop" \
-H "Authorization: Bearer <token>"
# Start
curl -X POST \
"https://<your-workspace>.azuredatabricks.net/api/2.0/apps/<app-name>/start" \
-H "Authorization: Bearer <token>"
When this works well: Apps used for specific recurring events (weekly reviews, monthly reporting) where someone responsible can start the app 2–3 minutes before the meeting and stop it afterward. Build this into your team’s meeting checklist and the discipline holds.
Where it breaks down: Nobody remembers. The app stays running all weekend. This is why automation matters.
Option 2: Scheduled Stop/Start with Databricks Jobs and WorkspaceClient
The most reliable hands-off approach: use a Databricks Job on a cron schedule to stop and start your apps automatically. Using the official Databricks SDK’s WorkspaceClient keeps the code clean, handles authentication automatically, and gives you typed access to the Apps API — no raw HTTP calls needed.
First, install the SDK if you’re setting this up locally or in a requirements file:
pip install databricks-sdk
The task notebook or script:
# app_scheduler.py — run as a Databricks Job task (serverless compute)
import json
from databricks.sdk import WorkspaceClient
from databricks.sdk.service.apps import AppState
# WorkspaceClient automatically picks up credentials from the job's
# execution context — no token or host config needed when running inside Databricks
w = WorkspaceClient()
# Read parameters passed in by the job
# ACTION = "stop" or "start"
# APP_NAMES = JSON list, e.g. '["app-one", "app-two", "app-three"]'
dbutils.widgets.text("ACTION", "stop")
dbutils.widgets.text("APP_NAMES", "[]")
action = dbutils.widgets.get("ACTION")
app_names = json.loads(dbutils.widgets.get("APP_NAMES"))
def stop_app(app_name: str):
app = w.apps.get(app_name)
if app.compute_status and app.compute_status.state == AppState.ACTIVE:
w.apps.stop(app_name)
print(f"✓ Stopped: {app_name}")
else:
print(f"– Already stopped: {app_name} (state: {app.compute_status.state})")
def start_app(app_name: str):
app = w.apps.get(app_name)
if app.compute_status and app.compute_status.state != AppState.ACTIVE:
w.apps.start(app_name)
print(f"✓ Started: {app_name}")
else:
print(f"– Already running: {app_name}")
for name in app_names:
try:
if action == "stop":
stop_app(name)
elif action == "start":
start_app(name)
except Exception as e:
print(f"✗ Error on {name}: {e}")
The SDK’s WorkspaceClient uses the job’s runtime credentials automatically — ino hardcoded tokens, no secrets to manage. It also checks the current AppState before acting, so stopping an already-stopped app (or starting an already-running one) won’t throw an error.
Creating the scheduled jobs via the SDK:
You can also provision the stop and start jobs themselves programmatically — useful if you’re managing this as part of an infrastructure-as-code setup:
# provision_app_scheduler_jobs.py — run once to create the jobs
from databricks.sdk import WorkspaceClient
from databricks.sdk.service.jobs import (
JobSettings, Task, NotebookTask, CronSchedule,
PauseStatus, JobParameterDefinition, Source
)
w = WorkspaceClient()
NOTEBOOK_PATH = "/Shared/ops/app_scheduler" # path to your notebook
APP_NAMES_JSON = '["my-dashboard", "sales-app", "ml-demo"]'
TIMEZONE = "America/New_York"
def create_scheduler_job(name: str, action: str, cron: str):
job = w.jobs.create(
settings=JobSettings(
name=name,
tasks=[
Task(
task_key="run_scheduler",
notebook_task=NotebookTask(
notebook_path=NOTEBOOK_PATH,
source=Source.WORKSPACE,
base_parameters={
"ACTION": action,
"APP_NAMES": APP_NAMES_JSON,
}
),
# Run on serverless — costs fractions of a cent per execution
environment_key="serverless",
)
],
schedule=CronSchedule(
quartz_cron_expression=cron,
timezone_id=TIMEZONE,
pause_status=PauseStatus.UNPAUSED,
),
parameters=[
JobParameterDefinition(name="ACTION", default=action),
JobParameterDefinition(name="APP_NAMES", default=APP_NAMES_JSON),
]
)
)
print(f"Created job '{name}' → job_id={job.job_id}")
return job.job_id
# Stop all apps at 7pm Monday–Friday
create_scheduler_job(
name="[databucks] Stop Apps — EOD",
action="stop",
cron="0 0 19 ? * MON-FRI",
)
# Start all apps at 7:45am Monday–Friday (ready before 8am standup)
create_scheduler_job(
name="[databucks] Start Apps ── Morning",
action="start",
cron="0 45 7 ? * MON-FRI",
)
Both jobs run on serverless compute — each execution takes a few seconds and costs fractions of a cent. The schedule itself has zero idle cost.
Listing current app states with WorkspaceClient (useful for auditing before setting up schedules):
from databricks.sdk import WorkspaceClient
w = WorkspaceClient()
print(f"{('App Name':<35} {('State':<12} {('URL'}")
print("-" * 80)
for app in w.apps.list():
state = app.compute_status.state.value if app.compute_status else "UNKNOWN"
url = app.url or "‒"
print(f"{app.name:<35} {state:<12} {url}")
Run this as a quick audit to see exactly which apps are ACTIVE (billing) vs. STOPPED across your workspace.
Option 3: Idle Detection with Auto-Stop Logic
For apps with unpredictable usage patterns — where you can’t set a fixed schedule — you can build a lightweight idle detection loop that monitors activity and stops the app after a period of inactivity.
The approach: track the last user interaction timestamp inside the app, and use a background thread to shut the app down if no activity has occurred within a threshold.
Example for a Streamlit app:
import streamlit as st
import threading
import time
import requests
import os
from datetime import datetime, timedelta
# ── Idle auto-stop configuration ──
IDLE_TIMEOUT_MINUTES = 30
WORKSPACE_HOST = os.environ["DATABRICKS_HOST"]
TOKEN = os.environ["DATABRICKS_TOKEN"]
APP_NAME = os.environ["APP_NAME"]
# Track last activity in session state
if "last_active" not in st.session_state:
st.session_state.last_active = datetime.now()
def update_activity():
st.session_state.last_active = datetime.now()
def stop_self():
"""Call the Databricks API to stop this app."""
requests.post(
f"{WORKSPACE_HOST}/api/2.0/apps/{APP_NAME}/stop",
headers={"Authorization": f"Bearer {TOKEN}"}
)
def idle_watcher():
"""Background thread: stop the app if idle too long."""
while True:
time.sleep(60) # check every minute
idle_duration = datetime.now() - st.session_state.last_active
if idle_duration > timedelta(minutes=IDLE_TIMEOUT_MINUTES):
print(f"App idle for {idle_duration} — stopping.")
stop_self()
# Start the watcher thread once
if "watcher_started" not in st.session_state:
t = threading.Thread(target=idle_watcher, daemon=True)
t.start()
st.session_state.watcher_started = True
# ── Your actual app UI ──*st.title("My Dashboard")
# Call update_activity() on meaningful interactions
if st.button("Refresh Data"):
update_activity()
# ... load data ...
# Show idle status in sidebar
idle_for = datetime.now() - st.session_state.last_active
st.sidebar.caption(f"Last activity: {int(idle_for.total_seconds() // 60)}m ago")
st.sidebar.caption(f"Auto-stop after {IDLE_TIMEOUT_MINUTES}m idle")
This is more code than a scheduled job, but it’s more intelligent — the app only stops when genuinely idle, not on a fixed clock. Useful for apps that get sporadic bursts of usage throughout the day.
Option 4: On-Demand Start via a Lightweight Landing Page
A creative pattern for apps that are used infrequently: replace the always-on app with a static landing page that triggers an on-demand start and tells the user to come back in 60–90 seconds.
The flow:
User clicks app link
→ Lightweight lobby app (near-zero cost, always-on)
→ WorkspaceClient checks current AppState
→ If STOPPED: starts the app, shows waiting page with auto-redirect
→ If ACTIVE: redirects immediately to the live app
→ Page redirects once app is ready
This can be implemented as a second minimal Databricks App (a simple Flask app) that acts as a lobby, using WorkspaceClient for all Databricks interactions:
# lobby_app.py — tiny Flask app that starts the real app on demand
# Requirements: databricks-sdk flask
from flask import Flask, render_template_string
from databricks.sdk import WorkspaceClient
from databricks.sdk.service.apps import AppState
import threading
import os
flask_app = Flask(__name__)
# WorkspaceClient picks up credentials automatically from the app's
# execution environment — no token or host config needed
w = WorkspaceClient()
TARGET_APP_NAME = os.environ["TARGET_APP_NAME"]
TARGET_APP_URL = os.environ["TARGET_APP_URL"]
LOBBY_HTML = """
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="5;url=/status">
<title>Starting ...</title>
<style>
body { font-family: sans-serif; max-width: 520px; margin: 80px auto; color: #333; }
.spinner { font-size: 2rem; animation: spin 1.5s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
a { color: #1a6b3c; }
</style>
</head>
<body>
<p class="spinner">⏳</p>
<h2>Starting …</h2>
<p>This usually takes <strong>60–90 seconds</strong>. This page checks every 5 seconds and will redirect you automatically.</p>
<p>Current status: <strong></strong></p>
<p>Or <a href="">click here</a> to try the app directly.</p>
</body>
</html>
"""
READY_HTML = """
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0;url=">
<title>Redirecting...</title>
</head>
<body>
<p>App is ready — <a href="">click here</a> if you are not redirected.</p>
</body>
</html>
"""
def get_app_state() -> AppState:
app = w.apps.get(TARGET_APP_NAME)
if app.compute_status:
return app.compute_status.state
return AppState.ERROR
def trigger_start():
"""Start the target app in a background thread so the lobby responds immediately."""
try:
state = get_app_state()
if state != AppState.ACTIVE:
w.apps.start(TARGET_APP_NAME)
except Exception as e:
print(f"Error starting app: {e}")
@flask_app.route("/")
def index():
state = get_app_state()
if state == AppState.ACTIVE:
# Already running — redirect straight through
return render_template_string(READY_HTML, target_url=TARGET_APP_URL)
# Not running — fire start in background, show waiting page
threading.Thread(target=trigger_start, daemon=True).start()
return render_template_string(
LOBBY_HTML,
app_name=TARGET_APP_NAME,
state=state.value if state else "UNKNOWN",
target_url=TARGET_APP_URL,
)
@flask_app.route("/status")
def status():
"""Polled by the meta-refresh every 5 seconds to check if the app is ready."""
state = get_app_state()
if state == AppState.ACTIVE:
return render_template_string(READY_HTML, target_url=TARGET_APP_URL)
return render_template_string(
LOBBY_HTML,
app_name=TARGET_APP_NAME,
state=state.value if state else "UNKNOWN",
target_url=TARGET_APP_URL,
)
if __name__ == "__main__":
flask_app.run(host="0.0.0.0", port=8080)
A few things worth noting about this version compared to a raw requests approach:
WorkspaceClientauto-authenticates from the lobby app’s execution environment — no tokens in environment variables or secrets to rotate- State-aware routing — the
/route callsw.apps.get()first. If the target app is alreadyACTIVE(e.g. someone else started it recently), users are redirected immediately with no unnecessary start call - Polling via
/status— the<meta http-equiv="refresh">bounces users to/statusevery 5 seconds, which re-checksAppStateeach time. Once the app flips toACTIVE, the next poll redirects them straight through - Background thread for the start call —
w.apps.start()is called in a daemon thread so the lobby page renders immediately while the start proceeds in the background
The lobby app itself stays always-on but is tiny — a single-worker Flask process with minimal compute — so its cost is negligible compared to keeping your full dashboard running 24/7. The real app only runs when someone actually needs it.
Choosing the Right Approach
| App usage pattern | Best approach |
|---|---|
| Fixed schedule (business hours, weekly meeting) | Scheduled Job (Option 2) |
| Unpredictable but active during the day | Idle detection (Option 3) |
| Rarely used, on-demand only | Lobby page + on-demand start (Option 4) |
| Demo/POC apps, used briefly | Manual stop (Option 1) + team discipline |
| Production app, always needs to be available | Don’t scale down — design for it |
These approaches can also be combined: use a scheduled job to stop apps at end of day, and idle detection during the day to catch apps that go unused for long stretches within business hours.
A Note on Cold Start Time
The main tradeoff with any scale-down strategy is startup latency. Databricks Apps currently take approximately 60–90 seconds to start from a stopped state. For a leadership dashboard that someone opens once a week, that’s completely acceptable. For a customer-facing tool that needs to be ready on first click, it’s not.
Design your scale-down strategy around the tolerance of your actual users:
- Internal tools, analyst dashboards, admin panels → scale down aggressively
- Customer-facing, SLA-bound, real-time → keep running or use the lobby pattern to set expectations
Getting Started
The fastest path to immediate savings:
- Audit your running apps — in the Databricks UI, go to Apps and note which apps are in
RUNNINGstate. Stop anything that doesn’t need to be running right now. - Pick your two or three highest-cost apps and set up a scheduled stop/start job for each (Option 2 — takes about 20 minutes to configure).
- Add idle detection to any Streamlit or Gradio apps that have variable usage patterns.
- Review monthly — check the Databricks cost dashboard, filter by
appsDBU type, and identify any new apps that have crept into always-on status.
The compute savings from scaling down idle apps are some of the easiest wins in a Databricks environment. No architecture changes, no migrations — just a bit of automation that pays for itself immediately.
Questions or want help auditing your app cost footprint? Reach out at info@databucks.ai.
Posted by the databucks.ai team · Back to home · info@databucks.ai
tags: databricks - apps - cost - scaling - idle - streamlit - gradio