iTranslated by AI
Preventing Duplicate LINE Bot Responses: Atomic Firestore Operations and a 3-Layer Guard Design
Introduction
When sending surveys (using button templates) via the LINE Messaging API, users can tap choices multiple times. This triggers a postback event for every tap, leading to issues such as multiple answer tags being assigned and duplicate entries being saved.
Furthermore, if a user manually sends a text message like "Yes." after answering, the scenario might restart via the message event.
This article explains a design and implementation that solves these problems using atomic duplicate prevention via Firestore's create() and by placing guards at three entry points.
What you will gain from this article:
- An atomic duplicate prevention pattern using Firestore
create(). - Deterministic document ID design using SHA-256 hashing.
- A migration strategy that maintains backward compatibility with legacy data.
- Guard design for the three entry points of LINE webhooks.
Overview
Here is an overview of the three paths where duplicate answers occur and the guards placed for each.
User Action Webhook Event Guard Location
────────────────────────────────────────────────────────────────────────────
① Tap choice multiple times → postback(survey_answer) → handleSurveyAnswer()
② Re-tap Flex image → postback(flex_tap) → handleFlexImageTap()
③ Send text (e.g., "Yes") → message → triggerScenariosByEvent()
↓
Firestore Presence Check
Already answered → Skip
Not answered → Proceed
Design Points
Point 1: Why use create()?
There are several ways to prevent duplicate writes in Firestore.
| Method | Pros | Cons |
|---|---|---|
set() + pre-query |
Simple implementation | Vulnerable to race conditions (two requests query simultaneously → both write) |
| Transaction | Strong consistency | Performance cost, requires retry logic |
create() + Deterministic ID |
Atomic, fast | Requires document ID design |
| Security Rules | Client-side control possible | Not needed if writing only via server (Admin SDK) |
Point 2: Designing Deterministic Document IDs
The key used for duplicate detection is the combination of scenarioId + nodeId + lineUserId. If this can be used as the Firestore document ID, the exclusive control of create() will work.
However, simple string concatenation can lead to collisions in ID delimiters.
// Dangerous example: Delimiter "_" might exist within IDs
"scenario_1" + "_" + "node_2" + "_" + "user_3"
// Indistinguishable from:
"scenario" + "_" + "1_node_2" + "_" + "user_3"
Therefore, we generate a deterministic ID using a SHA-256 hash.
import { createHash } from 'crypto';
function buildDeterministicDocId(
scenarioId: string,
nodeId: string,
lineUserId: string
): string {
// Using a NUL character as a delimiter ensures unique boundaries for each field
const payload = [scenarioId, nodeId, lineUserId].join('\0');
return createHash('sha256').update(payload).digest('hex').slice(0, 40);
}
Point 3: Backward Compatibility with Legacy Data
Existing data is saved with random IDs (nanoid). Since migrating to new deterministic IDs in bulk is high-risk, we adopt a strategy of checking both during reads.
Save Flow:
1. Check for legacy data (scenarioId + nodeId + lineUserId query)
2. If found → false (already answered)
3. If not found → execute create() with deterministic ID
4. If ALREADY_EXISTS → false
5. If successful → true
Presence Check:
1. doc.get() with deterministic ID (single document reference, fast path)
2. If exists → true
3. If not → fallback to legacy query
Implementation Tips
Tip 1: saveIfNotExists — Atomic Save Pattern
By providing an interface that returns a boolean for "whether it was saved," the calling logic remains simple.
// Repository interface
interface ISurveyResponseRepository {
save(response: SurveyResponse): Promise<void>;
saveIfNotExists(response: SurveyResponse): Promise<boolean>;
// Check answer existence for a specific node (fast path + legacy fallback)
existsByScenarioNodeAndUser(
scenarioId: string,
nodeId: string,
lineUserId: string
): Promise<boolean>;
// Check answer existence for the whole scenario (to prevent re-triggering)
existsByScenarioAndUser(
scenarioId: string,
lineUserId: string
): Promise<boolean>;
}
On the implementation side, we branch based on the error code thrown by Firestore's create().
async saveIfNotExists(response: SurveyResponse): Promise<boolean> {
const col = this.getCollection();
// Step 1: Check for legacy (random-ID) documents
const legacySnap = await col
.where('scenarioId', '==', response.scenarioId)
.where('nodeId', '==', response.nodeId)
.where('lineUserId', '==', response.lineUserId)
.limit(1)
.get();
if (!legacySnap.empty) return false;
// Step 2: Atomic create with deterministic ID
const docId = buildDeterministicDocId(
response.scenarioId,
response.nodeId,
response.lineUserId
);
try {
await col.doc(docId).create(response);
return true;
} catch (err: unknown) {
if (
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code: number }).code === 6
) {
return false; // Already exists
}
throw err;
}
}
Tip 2: Fast Path for Presence Checks
existsByScenarioNodeAndUser first attempts a get() using the deterministic ID. Since this is a single document reference, it is cheaper and faster than a query.
async existsByScenarioNodeAndUser(
scenarioId: string,
nodeId: string,
lineUserId: string
): Promise<boolean> {
const col = this.getCollection();
// Fast path: single document get (cheaper than query)
const docId = buildDeterministicDocId(scenarioId, nodeId, lineUserId);
const docSnap = await col.doc(docId).get();
if (docSnap.exists) return true;
// Slow path: query for legacy documents
const snap = await col
.where('scenarioId', '==', scenarioId)
.where('nodeId', '==', nodeId)
.where('lineUserId', '==', lineUserId)
.limit(1)
.get();
return !snap.empty;
}
As new data accumulates, the hit rate for the fast path will increase, and the frequency of legacy query execution will naturally decrease.
Tip 3: Placing the 3-Layer Guard
Duplicate access to the survey occurs through three paths, so guards are placed for each.
① Postback Answer Handler (Most Important)
Simply replacing save() with saveIfNotExists() allows conditional execution of tag assignment and the next node.
async handleSurveyAnswer(lineUserId, answer) {
// ... validate scenario/node/choice ...
- await this.upsertUser({ lineUserId, tags: [surveyTag, ...customTags] });
- await this.surveyResponseRepo.save(response);
+ const saved = await this.surveyResponseRepo.saveIfNotExists(response);
+ if (!saved) return; // Already answered — skip tag assignment
+
+ await this.upsertUser({ lineUserId, tags: [surveyTag, ...customTags] });
const nextNodeId = findNextNodeId(runtime, node.id, choice.value);
if (nextNodeId) {
await this.executeFromNode(runtime, nextNodeId, lineUserId);
}
}
② Flex Image Tap Handler
If an already-answered survey is re-tapped via a Flex image, scenario restart is skipped.
async handleFlexImageTap(lineUserId, postbackData) {
const payload = postbackData.replace('flex_tap:', '');
if (payload.startsWith('start_survey:')) {
const scenarioId = payload.replace('start_survey:', '');
+ const alreadyAnswered = await this.surveyResponseRepo
+ .existsByScenarioAndUser(scenarioId, lineUserId);
+ if (!alreadyAnswered) {
// ... start scenario ...
+ }
}
// flex_image_tap tag is still applied regardless
await this.upsertUser({ lineUserId, tags: ['flex_image_tap'] });
}
③ Message Event Trigger
This prevents the scenario from restarting when text is sent. However, because checking every scenario affects performance, we only check scenarios that contain survey nodes.
hasSurveyNode is a simple helper that returns whether the scenario's node array contains a survey type (runtime.nodes.some(n => n.type === 'survey')).
async triggerScenariosByEvent(runtimes, eventType, lineUserId) {
for (const runtime of runtimes) {
const triggers = findTriggerNodes(runtime, eventType);
if (triggers.length === 0) continue;
+ if (hasSurveyNode(runtime)) {
+ const answered = await this.surveyResponseRepo
+ .existsByScenarioAndUser(runtime.id, lineUserId);
+ if (answered) continue;
+ }
for (const triggerNode of triggers) {
// ... execute from next node ...
}
}
}
Pitfalls
Error Type of Firestore create()
Errors from the Firestore Admin SDK (internally @google-cloud/firestore) are compatible with Error, but behavior using instanceof for type checking can vary between SDK versions. It is more stable to check the code property of the gRPC status code.
// ❌ instanceof behavior can change with SDK versions
catch (err) {
if (err instanceof FirestoreError && err.code === 'already-exists') { ... }
}
// ✅ Determine via gRPC status code (Admin SDK)
catch (err: unknown) {
if (
typeof err === 'object' &&
err !== null &&
'code' in err &&
(err as { code: number }).code === 6
) {
return false;
}
throw err;
}
Administrator Manual Sends are Not Guarded
Functions where administrators "send this scenario to all users" from the dashboard are intentionally excluded from the guards, as there are cases where manual re-sending is intended. The guards are limited to webhook events triggered by users.
Conclusion
Summary of Design Decisions
| Decision | Choice | Reason |
|---|---|---|
| Method for duplicate prevention |
create() + Deterministic ID |
Atomic, no locks, fast |
| Document ID generation | SHA-256 hash (NUL-delimited) | Avoid collisions, fits Firestore ID limits |
| Legacy data support | Check both during reads | Avoid risks of bulk migration |
| Guard Scope | 3 Webhook-based entry points | Excludes admin manual sends |
| Filter for checking | hasSurveyNode() |
Avoids unnecessary DB access |
Key Learnings
-
Firestore
create()is an excellent, lightweight exclusive control — Lighter than transactions and resilient to race conditions. - Hash-based deterministic IDs are effective during transition phases — They allow coexistence with existing data while enabling gradual migration.
- Place guards at the "entry" points — Checking at the beginning of each entry point, rather than mid-process, keeps the logic simple.
-
Don't apply guards everywhere — By narrowing checks to cases where they are needed (using
hasSurveyNode()), performance is maintained.
Implementation Checklist
-
Are you catching gRPC code 6 in
create()error handling? - Will separator collisions occur in the deterministic ID generation logic?
- Is a fallback query for legacy data (random IDs) included?
- Are paths that shouldn't be guarded (like admin manual sends) excluded?
- Do scenarios without survey nodes avoid unnecessary checks?
Discussion