How to Implement Trial Expiration in a SaaS App

The complete developer guide to building trial expiration: database schema, server-side logic, client-side UI states, timezone edge cases, grace periods, and notification triggers. Includes Node.js and JavaScript code examples plus the done-for-you shortcut.

By TrialMoments Team12 min readPublished Mar 2026
80hrs
Full DIY Build
5 states
To Handle
5 min
With TrialMoments

Trial expiration is the most critical moment in your SaaS conversion funnel. It's the point where a free trial user either becomes a paying customer or churns. Yet most development teams treat it as an afterthought -- a simple date check that returns "trial expired" and hopes for the best.

A well-implemented trial expiration system has two layers: server-side enforcement (protecting your API and data) and client-side experience (guiding users toward conversion). The server layer ensures expired users can't access paid features. The client layer ensures they want to upgrade before they hit that wall.

This guide covers both layers end-to-end with production code examples. We'll build the server-side logic in Node.js, walk through all five client-side UI states, handle the edge cases that trip up most teams, and then show how TrialMoments handles the entire client-side layer so you only need the server portion.

Step 1: Database Schema for Trial Tracking

Your trial data model needs to track when the trial starts, when it ends, the current status, and any grace period. Here is a practical schema that handles the common scenarios:

-- PostgreSQL schema for trial tracking
CREATE TABLE subscriptions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  plan VARCHAR(50) NOT NULL DEFAULT 'trial',
  trial_start_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  trial_end_date TIMESTAMPTZ NOT NULL,
  grace_period_end TIMESTAMPTZ,
  trial_status VARCHAR(20) NOT NULL DEFAULT 'active',
  -- 'active', 'ending_soon', 'expired', 'grace', 'converted'
  upgraded_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Create index for efficient expiration queries
CREATE INDEX idx_subscriptions_trial_end
  ON subscriptions(trial_end_date)
  WHERE trial_status IN ('active', 'ending_soon');

-- Set trial_end_date on insert (14-day trial)
CREATE OR REPLACE FUNCTION set_trial_end_date()
RETURNS TRIGGER AS $$
BEGIN
  NEW.trial_end_date := NEW.trial_start_date + INTERVAL '14 days';
  NEW.grace_period_end := NEW.trial_end_date + INTERVAL '3 days';
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_set_trial_end
  BEFORE INSERT ON subscriptions
  FOR EACH ROW
  EXECUTE FUNCTION set_trial_end_date();

Key Design Decisions

  • Always use TIMESTAMPTZ (timezone-aware) not TIMESTAMP. This prevents timezone bugs when comparing server time against stored dates.
  • Store grace_period_end separately from trial_end_date. This lets you adjust grace periods without modifying the original trial end date.
  • Index on trial_end_date for your cron job that sends expiration notifications. Without this index, scanning for expiring trials becomes slow at scale.

Step 2: Server-Side Expiration Logic

Your server needs middleware that checks trial status on every authenticated request. Here's a Node.js/Express implementation:

// middleware/trialCheck.js
export function checkTrialStatus(req, res, next) {
  const { subscription } = req.user;

  // Paid users pass through
  if (subscription.plan !== 'trial') {
    return next();
  }

  const now = new Date();
  const trialEnd = new Date(subscription.trial_end_date);
  const graceEnd = new Date(subscription.grace_period_end);

  // Trial is still active
  if (now < trialEnd) {
    const daysRemaining = Math.ceil(
      (trialEnd - now) / (1000 * 60 * 60 * 24)
    );
    res.set('X-Trial-Days-Remaining', daysRemaining);
    res.set('X-Trial-End-Date', subscription.trial_end_date);
    return next();
  }

  // Trial expired but within grace period
  if (now < graceEnd) {
    res.set('X-Trial-Status', 'grace');
    res.set('X-Grace-Period-End', subscription.grace_period_end);

    // Allow read-only access during grace period
    if (req.method === 'GET') {
      return next();
    }

    return res.status(402).json({
      error: 'trial_expired',
      message: 'Your trial has expired. Upgrade to continue.',
      upgradeUrl: '/upgrade',
      graceEnds: subscription.grace_period_end,
    });
  }

  // Trial and grace period both expired
  return res.status(403).json({
    error: 'trial_fully_expired',
    message: 'Your trial and grace period have ended.',
    upgradeUrl: '/upgrade',
  });
}

// Apply to all API routes
app.use('/api', authenticate, checkTrialStatus);

Cron Job for Status Updates and Notifications

// jobs/trialExpiration.js
import { db } from '../db';
import { sendEmail } from '../email';

export async function processTrialExpirations() {
  const now = new Date();

  // Find trials ending in 3 days (send warning)
  const endingSoon = await db.query(`
    UPDATE subscriptions
    SET trial_status = 'ending_soon', updated_at = NOW()
    WHERE trial_status = 'active'
      AND trial_end_date <= NOW() + INTERVAL '3 days'
      AND trial_end_date > NOW()
    RETURNING user_id, trial_end_date
  `);

  for (const sub of endingSoon.rows) {
    await sendEmail(sub.user_id, 'trial-ending-soon', {
      daysLeft: Math.ceil(
        (new Date(sub.trial_end_date) - now) / 86400000
      ),
    });
  }

  // Find newly expired trials
  const expired = await db.query(`
    UPDATE subscriptions
    SET trial_status = 'expired', updated_at = NOW()
    WHERE trial_status IN ('active', 'ending_soon')
      AND trial_end_date <= NOW()
    RETURNING user_id
  `);

  for (const sub of expired.rows) {
    await sendEmail(sub.user_id, 'trial-expired', {
      graceHours: 72,
    });
  }

  // Find expired grace periods
  await db.query(`
    UPDATE subscriptions
    SET trial_status = 'fully_expired', updated_at = NOW()
    WHERE trial_status = 'expired'
      AND grace_period_end <= NOW()
  `);
}

// Run every hour
setInterval(processTrialExpirations, 60 * 60 * 1000);

Step 3: Client-Side UI States

The client side is where most teams underinvest. You need five distinct UI states, each with different messaging, urgency levels, and calls to action:

1

Active Trial (14-4 days remaining)

Low urgency. Show a subtle banner or badge with days remaining. Focus is on product value, not conversion. The welcome message appears on first login to set expectations.

Example: "14 days left in your trial - Explore all features"

2

Ending Soon (3-1 days remaining)

Medium urgency. Switch to a more prominent warning with countdown. Remind users of the value they've created. CTA becomes more prominent.

Example: "3 days left - You've created 12 projects. Upgrade to keep them."

3

Last Day (under 24 hours)

High urgency. Show a countdown timer in hours and minutes. The upgrade CTA should be the most prominent element on screen. Consider offering a last-day discount.

Example: "Trial ends in 8h 32m - Upgrade now and save 20%"

4

Grace Period (expired, 1-3 days grace)

Very high urgency. Allow read-only access so users can see their data. Show a persistent overlay or banner that blocks actions but not viewing. This creates loss aversion.

Example: "Your trial has ended. You have 2 days to upgrade and keep your data."

5

Fully Expired (no grace period)

Full block. Show a clean, professional overlay with upgrade options, a recap of what they built during the trial, and a clear path to recovery. Never make it hostile.

Example: "Your trial has ended. Upgrade to pick up where you left off."

Client-Side State Machine (JavaScript)

// trialStateMachine.js
// Client-side trial state management

function getTrialState(trialEndDate, gracePeriodEnd) {
  const now = new Date();
  const end = new Date(trialEndDate);
  const grace = new Date(gracePeriodEnd);
  const msRemaining = end - now;
  const daysRemaining = Math.ceil(msRemaining / 86400000);

  if (msRemaining > 3 * 86400000) {
    return {
      state: 'active',
      daysRemaining,
      urgency: 'low',
      message: `${daysRemaining} days left in your trial`,
    };
  }

  if (msRemaining > 86400000) {
    return {
      state: 'ending_soon',
      daysRemaining,
      urgency: 'medium',
      message: `Only ${daysRemaining} days left in your trial`,
    };
  }

  if (msRemaining > 0) {
    const hoursLeft = Math.ceil(msRemaining / 3600000);
    return {
      state: 'last_day',
      hoursRemaining: hoursLeft,
      urgency: 'high',
      message: `Trial ends in ${hoursLeft} hours`,
    };
  }

  if (now < grace) {
    const graceHours = Math.ceil((grace - now) / 3600000);
    return {
      state: 'grace_period',
      graceHoursRemaining: graceHours,
      urgency: 'critical',
      message: `Trial expired. ${graceHours}h to upgrade.`,
    };
  }

  return {
    state: 'fully_expired',
    urgency: 'blocked',
    message: 'Your trial has ended.',
  };
}

// Render appropriate UI based on state
function renderTrialUI(trialEndDate, gracePeriodEnd) {
  const trial = getTrialState(trialEndDate, gracePeriodEnd);

  switch (trial.state) {
    case 'active':
      showSubtleBanner(trial.message);
      break;
    case 'ending_soon':
      showWarningBanner(trial.message);
      break;
    case 'last_day':
      showCountdownTimer(trial.hoursRemaining);
      break;
    case 'grace_period':
      showGraceOverlay(trial.graceHoursRemaining);
      break;
    case 'fully_expired':
      showExpiredOverlay();
      break;
  }
}

// Each of these render functions is 50-100 lines of code
// covering: DOM creation, styling, responsive layout,
// dark mode, animations, dismissal logic, localStorage...
// Total for all 5 states: ~500 lines minimum

Step 4: Handling Edge Cases

Edge cases are where most trial expiration implementations break down. Here are the ones that catch teams off guard:

Timezone Handling

A user in New Zealand (UTC+12) signs up at 11 PM their time. Your server stores the trial start in UTC (11 AM same day). If you calculate "14 days" from server time, the user's trial ends half a day earlier than expected.

// Solution: always expire at end-of-day in user's timezone
function getTrialEndDate(startDate, durationDays, userTimezone) {
  const start = new Date(startDate);
  const end = new Date(start);
  end.setDate(end.getDate() + durationDays);

  // Convert to user's timezone end-of-day
  const formatter = new Intl.DateTimeFormat('en-US', {
    timeZone: userTimezone,
    year: 'numeric', month: '2-digit', day: '2-digit',
  });

  const userDate = formatter.format(end);
  // Set to 23:59:59 in user's timezone
  return new Date(`${userDate} 23:59:59 ${userTimezone}`);
}

Client Clock Manipulation

Users can set their system clock forward or backward. Never rely solely on client-side time for enforcement. Use the server as the source of truth and pass trial state to the client via API response headers.

// Pass trial state via API headers (see middleware above)
// Client reads from headers, not local clock
async function fetchTrialState() {
  const res = await fetch('/api/me');
  const daysLeft = res.headers.get('X-Trial-Days-Remaining');
  const endDate = res.headers.get('X-Trial-End-Date');
  const status = res.headers.get('X-Trial-Status');
  return { daysLeft: parseInt(daysLeft), endDate, status };
}

Trial Extension and Reactivation

Support and sales teams need the ability to extend trials. Build an admin endpoint that updates trial_end_date and resets trial_status to 'active'. Log all extensions for audit purposes. For reactivation after full expiration, create a separate flow that verifies the user's identity and resets the grace period.

Step 5: Notification Triggers

Notifications drive conversions by reaching users at critical decision points. Here's the notification schedule that maximizes trial-to-paid conversion:

TriggerChannelToneGoal
7 days beforeEmail + In-appInformationalRemind of trial timeline; recap features used
3 days beforeEmail + In-appUrgentCreate urgency; highlight value at risk
1 day beforeEmail + In-appFinal warningLast chance CTA; optional discount offer
Expiration dayEmail + In-appRecoveryExplain what's lost; clear upgrade path
Grace period endEmail onlyFinal recoveryData deletion warning; last chance to convert

In-App Notifications Convert 3-5x Better Than Email

Email open rates for trial expiration messages average 25-35%. In-app notifications reach 100% of active users at the moment they're most engaged. Combine both channels, but prioritize in-app for the highest conversion impact. This is exactly what TrialMoments provides out of the box.

The Done-for-You Shortcut: TrialMoments Handles the Client Side

Everything we've covered for client-side UI -- the five states, the countdown timer, the warning banners, the expired overlay, the responsive design, the dark mode support -- that's roughly 60-80 hours of development time. TrialMoments replaces all of it with a 5-minute integration.

Your team still implements the server-side logic (database schema, API enforcement, cron jobs). That's the part that must be custom to your stack. But the entire client-side experience is done for you:

<!-- Replace 500+ lines of custom client-side code with: -->
<script src="https://cdn.trialmoments.com/sdk.js"></script>
<script>
  // Fetch trial end date from your API
  fetch('/api/subscription')
    .then(res => res.json())
    .then(({ trialEndDate, plan }) => {
      if (plan === 'trial') {
        TrialMoments.init({
          accountId: 'your-account-id',
          trialEndDate: trialEndDate,
          upgradeUrl: 'https://yourapp.com/upgrade'
        });
      }
    });
</script>

<!-- TrialMoments automatically handles:
  ✓ Welcome message on first visit
  ✓ Countdown timer with days/hours remaining
  ✓ Expiration warning at 3 days / 1 day
  ✓ Trial-ended overlay with upgrade CTA
  ✓ Blocked feature prompts
  ✓ Dark mode detection
  ✓ Responsive design for all screen sizes
  ✓ Timezone-safe countdown calculations
  ✓ Dismissal persistence via localStorage
  ✓ Analytics on every interaction -->

Without TrialMoments

  • Build 5 UI state components
  • Handle responsive design
  • Implement dark mode
  • Write dismissal/persistence logic
  • Handle timezone edge cases
  • Design and write conversion copy
  • Build countdown timer logic
  • Test across browsers
  • Total: 60-80 hours

With TrialMoments

  • Add script tag (1 min)
  • Call init with 3 params (2 min)
  • Add blocked feature triggers (2 min)
  • Total: 5 minutes

Skip the Client-Side Build

Build the server-side trial logic your app needs. Let TrialMoments handle everything users see. 30KB, five conversion moments, 5-minute setup. It's the done-for-you trial expiration experience.

Frequently Asked Questions

How do I calculate trial expiration on the server side?

Store the trial_end_date as a UTC timestamp in your database when the user signs up (e.g., created_at + 14 days). On each API request, compare the current UTC time against this timestamp. If current time exceeds trial_end_date and the user hasn't upgraded, return a 402 or 403 status with a clear message indicating the trial has expired. Always use UTC on the server to avoid timezone inconsistencies.

What UI states do I need for trial expiration?

You need at least five distinct UI states: active trial (showing days remaining), ending soon (3-5 days left with increased urgency), last day (24-hour countdown with strong upgrade messaging), expired with grace period (limited access with upgrade prompt), and fully expired (blocked access with recovery options). Each state requires different copy, visual treatment, and call-to-action placement to maximize conversion.

How do I handle timezone issues with trial expiration?

Always store trial end dates in UTC on your server. When displaying the trial countdown on the client, convert from UTC to the user's local timezone using JavaScript's Intl.DateTimeFormat or Date methods. This prevents the situation where a user in UTC+12 sees their trial expire 12 hours before they expected. For fairness, many SaaS apps expire trials at end-of-day in the user's local timezone.

Should I offer a grace period after trial expiration?

Yes, a grace period of 2-7 days significantly improves conversion rates. During the grace period, allow read-only access to the user's data but block new actions. This creates urgency without hostility -- users can see their work but can't continue without upgrading. Companies that implement grace periods see 15-25% higher trial-to-paid conversion compared to hard cutoffs.

What notifications should I send before trial expiration?

Send notifications at four key intervals: 7 days before expiration (informational reminder), 3 days before (urgency with value recap), 1 day before (final warning with discount offer), and on expiration day (expired notice with recovery path). Combine email notifications with in-app messaging for maximum reach. In-app notifications convert 3-5x better than email alone.

Can TrialMoments handle trial expiration UI automatically?

Yes, TrialMoments handles the entire client-side trial expiration experience automatically. You provide the trial end date and upgrade URL during initialization, and the SDK manages all UI states: welcome messages, countdown timers, expiration warnings, and the trial-ended overlay. This means your engineering team only needs to implement the server-side trial logic while TrialMoments handles everything the user sees.

Focus on Your Server Logic. We Handle the Rest.

TrialMoments provides the complete client-side trial expiration experience. 30KB SDK, five proven conversion moments, 5-minute integration. Done for you.

Get Started with TrialMoments