Full-stack event ticketing platform with Redis-based concurrency control and real-time WebSocket inventory sync.
TicketBlitz is a full-stack ticketing system that handles the core problem of flash sales: selling limited inventory to many concurrent buyers without race conditions or oversells.
The purchase path runs through a Redis atomic DECREMENT before touching the database. Since Redis executes commands single-threadedly, this acts as a lock-free concurrency gate — 1,000 simultaneous buyers competing for 1 ticket produce exactly 1 order. If the subsequent PostgreSQL write fails, the Redis counter is restored via a compensating increment.
Stock changes are broadcast to all connected browser tabs over WebSocket (STOMP) in real time.
- Tech Stack
- Features
- Architecture
- Frontend
- Backend
- Data Model
- Redis Purchase Engine
- API Reference
- Setup & Installation
- Testing
- Screenshots
| Layer | Technology | Version |
|---|---|---|
| Frontend | React, Vite | 19.2 / 7.2 |
| Routing | React Router DOM | 7.12 |
| HTTP | Axios | 1.13 |
| WebSocket (client) | stompjs, sockjs-client | 2.3 / 1.6 |
| PDF & QR | jsPDF, html2canvas, qrcode.react | 4.0 / 1.4 / 4.2 |
| Charts | Recharts | 3.6 |
| Icons | Lucide React | 0.562 |
| Backend | Spring Boot, Java 17 | 4.0.1 |
| Security | Spring Security, JJWT (HS256) | 0.11.5 |
| Database | PostgreSQL | 15+ |
| ORM | Spring Data JPA / Hibernate | — |
| Cache / Concurrency | Redis, Lettuce client | 7+ |
| Real-time | Spring WebSocket (STOMP) | — |
| Build | Maven Wrapper, npm | — |
- Atomic inventory control — Redis
DECREMENTas the concurrency gate; no database locks on the purchase hot path - Compensating transactions — Redis is restored if the PostgreSQL write fails, preventing phantom reservations
- Real-time stock sync — WebSocket broadcasts
{ tierId, remaining }to all tabs on every purchase - Client-side ticket generation — PDF with embedded QR code rendered entirely in the browser via
html2canvas+jsPDF - Simulated payment flow — 2-second modal with a unique
TXN_BLITZ_XXXXID before the API call fires - Event reviews — Star ratings (1–5), text, and images; edit/delete restricted to the original author
- Partner portal — B2B inquiry submission with admin-side status management (PENDING / REVIEWED / APPROVED)
- Admin dashboard — System stats (users, revenue, tickets sold via Recharts), full event/venue/user/partner CRUD
- Role-based access —
CUSTOMERandADMINroles embedded in JWT; enforced server-side via@PreAuthorize
React (Vite) ──── Axios ───────────────────────────► Spring Boot :8080
│
POST /api/stock/purchase │
└─ StockService.processPurchase() │
├─ Redis DECREMENT (atomic) │
│ ├─ result >= 0 → continue │
│ └─ result < 0 → refund, reject │
├─ PostgreSQL: update tier stock │
├─ PostgreSQL: insert order │
└─ WebSocket broadcast │
│
WebSocket (SockJS/STOMP) ◄── /topic/stock/{eventId} ─────────┘
event:{eventId}:tier:{tierId} → "42" (string integer)
backend/
├── auth/ Register + authenticate endpoints and service
├── config/ Security, CORS, JWT filter, WebSocket broker
├── controller/ Admin, Event, Order, Ticket, Venue, Review, Partner
├── service/ StockService (Redis + WS), AdminService (event lifecycle + stats)
├── model/ JPA entities
├── repository/ Spring Data JPA with custom JPQL queries
├── dto/ OrderDto, SystemStatsDto, UserDto, StatusUpdateRequest
└── exception/ GlobalExceptionHandler
frontend/src/
├── pages/ HomePage, TicketPage, MyTickets, AdminDashboard, PartnerWithUs, HelpCenter
├── components/ Navbar, AdminRoute, AdminEventForm, EventTable, VenueManager, ProposalsTracker, PastEventsSection
└── utils/ auth.js (isTokenExpired), authUtils.js (getUserRoleFromToken)
| Route | Page | Description |
|---|---|---|
/ and /home |
HomePage |
Event grid with live search (title + city) and category filters |
/tickets/:eventId |
TicketPage |
Tier selection, WebSocket subscription, seating map lightbox, payment modal, confetti on success |
/my-tickets |
MyTickets |
Digital wallet — enriched order list with PDF/QR download |
/admin |
AdminDashboard |
Stats, event/venue/user/partner management |
/partner |
PartnerWithUs |
Multi-step B2B inquiry form (public — no auth required) |
/help |
HelpCenter |
Support hub (public — no auth required) |
App.jsx checks isTokenExpired() on every protected route render. Expired tokens trigger localStorage.clear() and redirect to login. AdminRoute.jsx additionally reads the role claim via getUserRoleFromToken() and blocks non-admins from /admin.
html2canvasrenders the styled ticket component to a<canvas>(3× scale for high-DPI)jsPDFconverts the canvas to a downloadable PDF blob saved asTicketBlitz_Pass_{id}.pdfqrcode.react(QRCodeSVG) embeds a QR code encoding the order ID as a string
After a successful purchase, TicketPage navigates to /my-tickets with { state: { confetti: true } }. On mount, MyTickets checks location.state?.confetti and fires a 3-second canvas-confetti burst animation — then clears the flag via window.history.replaceState so it doesn't re-trigger on back navigation.
| Path Pattern | Access |
|---|---|
/api/auth/**, /ws/**, /error |
Public |
POST /api/partners/apply |
Public |
GET /api/partners/my-proposals |
Authenticated |
/api/admin/** |
ROLE_ADMIN |
/api/stock/** |
Authenticated |
/api/events/** |
Authenticated |
| All others | Authenticated |
Session policy is STATELESS. CSRF is disabled. CORS allows http://localhost:5173 with credentials.
Tokens are HS256-signed and include sub (email), role (CUSTOMER or ADMIN), iat, and exp. Default expiry is 24 hours (configurable). JwtAuthenticationFilter validates the Authorization: Bearer header on every request and populates SecurityContextHolder.
StockService — owns all Redis operations and WebSocket broadcasts.
AdminService — owns event lifecycle (create / update / delete) with Redis synchronization, and aggregates dashboard statistics.
User id, name, email, password, role (CUSTOMER | ADMIN)
Event id, title, description, imageUrl, eventDate, eventTime,
category, venue_id → Venue
ticketTiers[] → TicketTier (CascadeType.ALL)
galleryImages[] → @ElementCollection
reviews[] → Review
@Transient getAverageRating()
TicketTier id, tierName, price, availableStock (@Min 0), benefits, event_id
Order id, userId (email), event_id → Event, tierName,
quantity, totalAmount, orderTime
Venue id, name, address, city, totalCapacity, seatingMapUrl
Review id, userId, userName, rating (int, no backend constraint), comment,
reviewImages[] → @ElementCollection, event_id, createdAt
PartnerInquiry id, organizerName, businessEmail, eventCategory,
estimatedAttendance, contactNumber, city, eventDate,
eventProposal, submittedByEmail, submittedAt,
status (PENDING | REVIEWED | APPROVED)
// Prevents LazyInitializationException — eagerly joins tiers and venue
@Query("SELECT DISTINCT e FROM Event e LEFT JOIN FETCH e.ticketTiers LEFT JOIN FETCH e.venue")
List<Event> findAllWithTiers();
// Case-insensitive fuzzy search across title and venue city
@Query("SELECT e FROM Event e WHERE LOWER(e.title) LIKE LOWER(CONCAT('%',:query,'%')) " +
"OR LOWER(e.venue.city) LIKE LOWER(CONCAT('%',:query,'%'))")
List<Event> searchEvents(@Param("query") String query);
// Eager-loads event + venue to populate OrderDto (fixes missing date/venue in wallet)
@Query("SELECT o FROM Order o JOIN FETCH o.event e JOIN FETCH e.venue WHERE o.userId = :userId")
List<Order> findByUserIdWithDetails(@Param("userId") String userId);
// Null-safe aggregates for dashboard stats
@Query("SELECT COALESCE(SUM(o.quantity), 0) FROM Order o")
Long sumTotalTicketsSold();
@Query("SELECT COALESCE(SUM(o.totalAmount), 0.0) FROM Order o")
Double sumTotalRevenue();| Approach | Mechanism | Problem under high concurrency |
|---|---|---|
Pessimistic lock (SELECT FOR UPDATE) |
DB row locked per transaction | All requests queue at the DB |
| Optimistic lock (version field) | Retry on version mismatch | Retry storms under contention |
Redis DECREMENT ✅ |
Single-threaded O(1) command | No contention; DB only touched for winners |
@Transactional(rollbackFor = Exception.class)
public boolean processPurchase(Long eventId, Long tierId, String userId, int quantity) {
String stockKey = String.format("event:%d:tier:%d", eventId, tierId);
// 1. Atomic Redis decrement — no race condition possible
Long remaining = redisTemplate.opsForValue().decrement(stockKey, quantity);
if (remaining != null && remaining >= 0) {
try {
// 2. Fetch entities from DB
TicketTier tier = ticketTierRepository.findById(tierId).orElseThrow(...);
Event event = eventRepository.findById(eventId).orElseThrow(...);
// 3. Sync tier stock in PostgreSQL
tier.setAvailableStock(remaining.intValue());
ticketTierRepository.saveAndFlush(tier);
// 4. Persist order record
orderRepository.save(Order.builder()
.userId(userId).event(event).tierName(tier.getTierName())
.quantity(quantity).totalAmount(tier.getPrice() * quantity)
.orderTime(LocalDateTime.now()).build());
// 5. Push updated stock to all WebSocket subscribers
broadcastStockUpdate(eventId, tierId, remaining);
return true;
} catch (Exception e) {
// 6. DB failed — restore Redis so inventory is not lost
redisTemplate.opsForValue().increment(stockKey, quantity);
throw new RuntimeException("DB write failed. Redis restored.", e);
}
} else {
// 7. Over-decremented (sold out) — restore and reject
redisTemplate.opsForValue().increment(stockKey, quantity);
return false;
}
}public void initializeStock(Long eventId, Long tierId, int amount) {
if (eventId == null || tierId == null) return; // Prevents "event:null:tier:null" keys
String key = String.format("event:%d:tier:%d", eventId, tierId);
redisTemplate.opsForValue().set(key, String.valueOf(amount));
broadcastStockUpdate(eventId, tierId, (long) amount);
}| Operation | Flow |
|---|---|
| Create | saveAndFlush() (generates IDs) → initializeStock() per tier → syncStockToRedis() (second pass to guarantee correctness) |
| Update | Merge fields/tiers/venue, re-link tier parent references → saveAndFlush() → initializeStock() per tier |
| Delete | redisTemplate.delete() per tier key → eventRepository.delete() (JPA cascade handles orders/tiers/reviews) |
The saveAndFlush() + syncStockToRedis() two-pass pattern ensures JPA has generated real IDs before Redis is written, preventing the null-key cache poisoning bug.
| Method | Endpoint | Auth | Body / Response |
|---|---|---|---|
| POST | /api/auth/register |
Public | { name, email, password } → { token, role, name } |
| POST | /api/auth/authenticate |
Public | { email, password } → { token, role, name } |
| Method | Endpoint | Auth |
|---|---|---|
| GET | /api/events |
User |
| GET | /api/events/{id} |
User |
| GET | /api/events/search?q= |
User |
| GET | /api/events/past |
User |
| POST | /api/events/create |
Admin |
| PUT | /api/events/{id} |
Admin |
| DELETE | /api/events/{id} |
Admin |
| Method | Endpoint | Auth | Notes |
|---|---|---|---|
| POST | /api/stock/purchase |
User | { eventId, tierId, quantity } |
| GET | /api/stock/my-tickets |
User | Returns enriched OrderDto[] with event + venue details |
| GET | /api/stock/count/{eventId}/{tierId} |
User | Current Redis stock count |
| Method | Endpoint | Auth |
|---|---|---|
| GET | /api/orders/my-orders |
User |
| Method | Endpoint | Auth |
|---|---|---|
| GET | /api/venues |
User |
| GET | /api/venues/{id} |
User |
| POST | /api/venues |
Admin |
| PUT | /api/venues/{id} |
Admin |
| DELETE | /api/venues/{id} |
Admin |
| Method | Endpoint | Auth | Notes |
|---|---|---|---|
| POST | /api/reviews/{eventId} |
User | |
| GET | /api/reviews/{eventId} |
User | Sorted newest first |
| PUT | /api/reviews/{reviewId} |
User | Owner only (403 otherwise) |
| DELETE | /api/reviews/{reviewId} |
User | Owner only (403 otherwise) |
| Method | Endpoint | Auth |
|---|---|---|
| POST | /api/partners/apply |
Public |
| GET | /api/partners/my-proposals?email= |
User |
| GET | /api/admin/partners |
Admin |
| PUT | /api/admin/partners/{id}/status |
Admin |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/admin/stats |
{ totalUsers, totalTicketsSold, totalRevenue } |
| GET | /api/admin/orders |
All orders as enriched OrderDto[] |
| GET | /api/admin/users |
All users as UserDto[] |
| DELETE | /api/admin/users/{id} |
Delete user |
| POST | /api/admin/sync-stock |
Re-sync all Redis inventory from PostgreSQL |
| POST | /api/admin/init |
Reset a specific tier's Redis stock |
| GET / POST / DELETE | /api/admin/venues |
Venue management |
Connect to ws://localhost:8080/ws via SockJS, then subscribe:
/topic/stock/{eventId} → { "tierId": 3, "remaining": 41 }
- Java 17+
- Node.js 18+
- PostgreSQL 15+ (port 5432)
- Redis 7+ (port 6379)
- Maven (wrapper included)
1. Create the database
CREATE DATABASE ticketblitz;2. Configure backend/src/main/resources/application.properties
spring.application.name=TicketBlitz
# PostgreSQL
spring.datasource.url=jdbc:postgresql://localhost:5432/ticketblitz
spring.datasource.username=postgres
spring.datasource.password=YOUR_PASSWORD
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
# Redis
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.lettuce.pool.max-active=20
spring.data.redis.lettuce.pool.max-idle=10
spring.data.redis.lettuce.pool.min-idle=5
# HikariCP
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
spring.transaction.default-timeout=10
server.port=8080
# JWT — use environment variables in production
application.security.jwt.secret-key=YOUR_256_BIT_BASE64_SECRET
application.security.jwt.expiration=86400000
# Logging
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.springframework.orm.jpa=DEBUG3. Start Redis
redis-server
# Verify
redis-cli ping # → PONG4. Run the backend
# Linux/Mac
./mvnw spring-boot:run
# Windows
mvnw.cmd spring-boot:runOn startup, the CommandLineRunner in BackendApplication writes and reads a test key to confirm Redis connectivity:
⚡ TESTING REDIS CONNECTION...
⚡ REDIS RESPONSE: TicketBlitz is Live!
Backend: http://localhost:8080
cd frontend
npm install
npm run devFrontend: http://localhost:5173
Register normally via the UI, then update the role in the database:
UPDATE users SET role = 'ADMIN' WHERE email = 'your@email.com';Log out and back in to receive a new JWT with the ADMIN claim embedded.
redis-cli ping # PONG
curl http://localhost:8080/api/events # [] or event array
psql -U postgres -d ticketblitz -c "SELECT version();"Real-time test: Open the same event in two browser tabs. Purchase a ticket in one and verify the stock counter updates in the other without refreshing.
Two integration tests are included. Both require live PostgreSQL and Redis instances. Data is set up in @BeforeEach and cleaned with deleteAll() before each run.
1,000 concurrent threads compete for 1 ticket via StockService.processPurchase() directly.
final int TOTAL_STOCK = 1;
final int CONCURRENT_USERS = 1000;Threads are held on a CountDownLatch and released simultaneously. Assertions:
assertEquals(1, successCount.get()); // exactly one winner
assertEquals(999, failCount.get()); // everyone else rejected
assertEquals(1, orderRepository.count()); // one order in DB
assertEquals(0, finalDbStock); // tier stock at zero100 concurrent requests to POST /api/stock/purchase via MockMvc with full Spring Security context, against an event with 5 tickets.
assertEquals(5, successCount.get()); // five HTTP 200 responses
assertEquals(95, failCount.get()); // ninety-five HTTP 400 responsescd backend
./mvnw test # all tests
./mvnw test -Dtest=StockServiceStressTest
./mvnw test -Dtest=ControllerLoadTestBuilt with Spring Boot & React git · © 2026 Yogesh Shende










