How to Build a Movie Theater Seat Availability API with Node.js and PostgreSQL

How to Build a Movie Theater Seat Availability API with Node.js and PostgreSQL

Movie theater chains lose millions annually because roughly 10% of showings sell zero tickets—yet audiences often don't know alternative times exist. If you're building a ticketing platform, cinema aggregator, or theater optimization tool, you need a robust API to track and expose real-time seat availability across multiple venues.

This guide walks you through building a production-ready seat availability API that theaters can integrate with, allowing you to identify empty screenings and recommend alternatives to users.

Why Theater Occupancy Data Matters for Developers

Traditional ticketing systems expose minimal occupancy data to third-party developers. You face challenges like:

  • Fragmented data sources: Each theater chain uses proprietary systems
  • Stale information: Availability data cached for hours becomes useless
  • No bulk query support: Checking 50 showtimes requires 50 separate requests
  • Rate limiting: Scraping theater sites violates ToS and gets blocked

Building your own availability service solves these problems by creating a single source of truth. Users in the "Walzr" use case (finding empty screenings for quiet viewing) benefit from consistent, real-time data across multiple venues.

Architecture Overview

Your API will track three entities:

  1. Theaters: Venue locations with multiple screens
  2. Showings: Individual movie screenings with showtimes
  3. Availability: Real-time seat counts and occupancy percentages

Data flows from external sources (theater APIs, web scraping, or user uploads) into your database, then serves via REST endpoints that clients query.

Step 1: Set Up Your Database Schema

Start with PostgreSQL to handle relational data and concurrent updates:

CREATE TABLE theaters (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  city VARCHAR(100),
  state VARCHAR(2),
  latitude DECIMAL(9, 6),
  longitude DECIMAL(9, 6),
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE screens (
  id SERIAL PRIMARY KEY,
  theater_id INT NOT NULL REFERENCES theaters(id) ON DELETE CASCADE,
  screen_number INT,
  total_seats INT NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE showings (
  id SERIAL PRIMARY KEY,
  screen_id INT NOT NULL REFERENCES screens(id) ON DELETE CASCADE,
  movie_title VARCHAR(255),
  showing_time TIMESTAMP NOT NULL,
  format VARCHAR(50), -- 2D, IMAX, Dolby, etc
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  UNIQUE(screen_id, showing_time)
);

CREATE TABLE availability (
  id SERIAL PRIMARY KEY,
  showing_id INT NOT NULL REFERENCES showings(id) ON DELETE CASCADE,
  seats_available INT NOT NULL,
  seats_reserved INT DEFAULT 0,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  data_source VARCHAR(50) -- 'api', 'manual', 'scrape'
);

CREATE INDEX idx_showing_time ON showings(showing_time);
CREATE INDEX idx_theater_city ON theaters(city, state);
CREATE INDEX idx_availability_updated ON availability(updated_at);

Step 2: Build the Node.js Express Server

Set up a server that ingests availability data and exposes endpoints:

const express = require('express');
const { Pool } = require('pg');
const rateLimit = require('express-rate-limit');

const app = express();
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

app.use(express.json());

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
});

app.use(limiter);

// Get empty or near-empty screenings in a city
app.get('/api/v1/empty-screenings', async (req, res) => {
  const { city, state, threshold = 10 } = req.query;

  if (!city || !state) {
    return res.status(400).json({ error: 'city and state required' });
  }

  try {
    const result = await pool.query(
      `SELECT 
        t.name AS theater_name,
        t.city,
        s.screen_number,
        sh.movie_title,
        sh.showing_time,
        sc.total_seats,
        av.seats_available,
        ROUND(100.0 * av.seats_available / sc.total_seats, 2) AS occupancy_pct,
        av.updated_at
      FROM theaters t
      JOIN screens sc ON t.id = sc.theater_id
      JOIN showings sh ON sc.id = sh.screen_id
      JOIN availability av ON sh.id = av.showing_id
      WHERE LOWER(t.city) = LOWER($1) 
        AND UPPER(t.state) = UPPER($2)
        AND sh.showing_time > NOW()
        AND av.seats_available > 0
        AND (100.0 * av.seats_available / sc.total_seats) <= $3
      ORDER BY sh.showing_time ASC`,
      [city, state, threshold]
    );

    res.json({
      count: result.rows.length,
      data: result.rows,
    });
  } catch (error) {
    console.error(error);
    res.status(500).json({ error: 'Database query failed' });
  }
});

// Update availability (for theater API integrations)
app.post('/api/v1/availability/update', async (req, res) => {
  const { showing_id, seats_available, data_source } = req.body;

  try {
    const result = await pool.query(
      `INSERT INTO availability (showing_id, seats_available, data_source)
       VALUES ($1, $2, $3)
       ON CONFLICT (showing_id) DO UPDATE 
       SET seats_available = $2, updated_at = CURRENT_TIMESTAMP
       RETURNING *`,
      [showing_id, seats_available, data_source || 'api']
    );

    res.json(result.rows[0]);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Get all showings at a theater
app.get('/api/v1/theaters/:theater_id/showings', async (req, res) => {
  const { theater_id } = req.params;
  const { date } = req.query; // YYYY-MM-DD format

  try {
    const result = await pool.query(
      `SELECT 
        sh.id,
        sh.movie_title,
        sh.showing_time,
        sh.format,
        sc.screen_number,
        sc.total_seats,
        av.seats_available,
        ROUND(100.0 * av.seats_available / sc.total_seats, 2) AS occupancy_pct
      FROM showings sh
      JOIN screens sc ON sh.screen_id = sc.id
      JOIN availability av ON sh.id = av.showing_id
      WHERE sc.theater_id = $1
        AND (DATE(sh.showing_time) = $2 OR $2 IS NULL)
      ORDER BY sh.showing_time`,
      [theater_id, date]
    );

    res.json(result.rows);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

app.listen(process.env.PORT || 3000, () => {
  console.log('Theater API running on port 3000');
});

Step 3: Handle Real-Time Updates

For production, you need fresh data. Options include:

Option A: Theater API Integration (most reliable) If theaters expose APIs, write connectors:

// Example: AMC Theater API connector
async function updateAMCAvailability() {
  const theatersInDB = await pool.query('SELECT id, external_id FROM theaters WHERE chain = \'AMC\'');
  
  for (const theater of theatersInDB.rows) {
    try {
      const response = await fetch(`https://api.amctheatres.com/showings?theater=${theater.external_id}`);
      const data = await response.json();
      
      for (const showing of data.showings) {
        await pool.query(
          `INSERT INTO availability (showing_id, seats_available, data_source)
           VALUES ((SELECT id FROM showings WHERE external_id = $1), $2, 'amcapi')
           ON CONFLICT (showing_id) DO UPDATE SET seats_available = $2`,
          [showing.id, showing.seatsAvailable]
        );
      }
    } catch (error) {
      console.error(`Failed to update AMC theater ${theater.id}:`, error);
    }
  }
}

// Run every 15 minutes
setInterval(updateAMCAvailability, 15 * 60 * 1000);

Option B: Web Scraping (use cautiously) For theaters without APIs, consider Cheerio or Puppeteer for lightweight scraping within fair-use limits.

Step 4: Performance Optimization

For high query volumes, add caching and indexing:

const Redis = require('redis');
const client = Redis.createClient();

app.get('/api/v1/empty-screenings', async (req, res) => {
  const cacheKey = `empty-screenings:${req.query.city}:${req.query.state}`;
  
  // Check cache first
  const cached = await client.get(cacheKey);
  if (cached) {
    return res.json(JSON.parse(cached));
  }
  
  // ... database query ...
  
  // Cache for 5 minutes
  await client.setex(cacheKey, 300, JSON.stringify(result.rows));
  res.json(result.rows);
});

Deployment Considerations

| Aspect | Recommendation | |--------|----------------| | Database | Use managed PostgreSQL (DigitalOcean, Render, or AWS RDS) for automatic backups | | Server | Deploy on Vercel, Render, or DigitalOcean App Platform for auto-scaling | | Caching | Redis for availability queries; consider CDN for static theater data | | Monitoring | Log API response times and failed theater updates | | Rate Limiting | Implement per-user limits to prevent abuse |

Next Steps

  1. Add geospatial queries: Use PostGIS for "find empty screenings within 10 miles"
  2. Build a web dashboard: Show occupancy trends by movie/theater
  3. Integrate user preferences: Let users save favorite theaters and get alerts
  4. Monetize thoughtfully: Offer theater chains analytics dashboards without exposing real-time availability to competitors

This architecture scales to thousands of theaters and millions of daily queries while maintaining sub-second response times.

Recommended Tools

  • DigitalOceanCloud hosting built for developers — $200 free credit for new users
  • RenderZero-DevOps cloud platform for web apps and APIs
  • VercelDeploy frontend apps instantly with zero config