Gemini 1.5-flash intermittently wraps JSON output in markdown code fences or includes explanation text before/after the JSON object. JSON.parse() throws SyntaxError, Cloud Function crashes, client receives no response and shows infinite spinner. Fix: pre-parse cleaning + structured error return.
Gemini is a language model, not a JSON serializer. It produces text that looks like JSON most of the time, but it can and does add decoration — markdown code fences, explanation sentences, commentary after the closing brace — especially when the prompt does not explicitly constrain the output format. JSON.parse() treats all of this as fatal.
This failure was first observed in ScamCheck (February 2026) during heavy testing after the Gemini integration was initially working. The model was returning valid JSON for most inputs but failing on a subset of inputs, particularly those that triggered longer model reasoning about ambiguous scam signals.
Gemini's text output for a requested JSON response can look like any of these:
Expected (valid):
{"verdict":"LIKELY_SCAM","probability":0.85,"signals":["urgency","financial_request"],"explanation":"..."}
Observed failure — markdown code fence:
```json
{"verdict":"LIKELY_SCAM","probability":0.85,"signals":["urgency","financial_request"],"explanation":"..."}
```
Observed failure — explanation prefix:
Here is the analysis result:
{"verdict":"LIKELY_SCAM","probability":0.85,"signals":["urgency","financial_request"],"explanation":"..."}
Observed failure — trailing commentary:
{"verdict":"LIKELY_SCAM","probability":0.85,"signals":["urgency","financial_request"],"explanation":"..."}
Note: this input contained multiple overlapping scam patterns.
JSON.parse() throws SyntaxError on all three. Without error handling, the Cloud Function crashes with an unhandled exception.
This function handles the three observed failure modes before JSON.parse() is called:
// functions/src/utils/cleanGeminiOutput.js
function cleanGeminiOutput(raw) {
if (typeof raw !== 'string') return raw
let text = raw.trim()
// Strip markdown code fences: ```json ... ``` or ``` ... ```
text = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim()
// Extract JSON substring — find first { and last }
const firstBrace = text.indexOf('{')
const lastBrace = text.lastIndexOf('}')
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
text = text.slice(firstBrace, lastBrace + 1)
}
return text
}
module.exports = { cleanGeminiOutput }
Wrap JSON.parse() in a try/catch, return a structured error response instead of letting the exception propagate:
// functions/src/analyzeContent.js
const { cleanGeminiOutput } = require('./utils/cleanGeminiOutput')
async function callGemini(input) {
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' })
const prompt = buildPrompt(input)
const result = await model.generateContent(prompt)
const rawText = result.response.text()
// Clean before parsing
const cleanedText = cleanGeminiOutput(rawText)
let parsed
try {
parsed = JSON.parse(cleanedText)
} catch (err) {
console.error('Gemini JSON parse failure:', {
rawLength: rawText.length,
rawPreview: rawText.slice(0, 200),
cleanedPreview: cleanedText.slice(0, 200),
error: err.message,
})
return { ok: false, parseError: true }
}
// Schema validation — check required fields exist
if (!parsed.verdict || typeof parsed.probability !== 'number') {
console.error('Gemini output missing required fields:', parsed)
return { ok: false, parseError: true }
}
return { ok: true, verdict: parsed }
}
Log the raw output on failure. The rawPreview log is critical for diagnosing new output format variations after model updates.
// React component
const result = await analyzeContent({ input: userInput })
if (result.data.parseError) {
setError('Could not parse analysis result. Please try again.')
setLoading(false)
return
}
if (result.data.rateLimited) {
setError('Rate limit reached — please wait a few seconds and try again.')
setLoading(false)
return
}
// Happy path
setVerdict(result.data.verdict)
setLoading(false)
The finally block is mandatory:
try {
const result = await analyzeContent({ input: userInput })
// handle result
} catch (err) {
setError('Analysis failed. Please try again.')
} finally {
setLoading(false) // CRITICAL — always clear the spinner
}
Without the finally block, any unhandled error path leaves loading: true forever. The spinner becomes permanent.
The pre-parse cleaning handles failures after the fact. Reducing their frequency requires tighter prompt constraints:
Ineffective:
Analyze this input and return a JSON response.
Effective — embed exact schema in the prompt:
You are a scam detection system. Analyze the following input and return ONLY a JSON object with no other text, no markdown formatting, no code fences, and no explanation.
Required output format:
{
"verdict": "LIKELY_SCAM" | "UNLIKELY_SCAM" | "UNCERTAIN",
"probability": <number between 0 and 1>,
"signals": [<array of signal strings>],
"explanation": "<plain language explanation>"
}
Return ONLY the JSON object. Do not wrap it in code fences. Do not add any text before or after the JSON.
The explicit instruction "Do not wrap it in code fences. Do not add any text before or after the JSON" measurably reduces the incidence of decorated output. It does not eliminate it — the pre-parse cleaning layer is still required.
Gemini is a text generation model trained to produce helpful, well-formatted responses. In chat contexts, JSON wrapped in markdown code fences is correct behavior — it improves readability. The model has no inherent awareness that it is being called from a Cloud Function where markdown is fatal.
The model's tendency toward decorated output increases when:
This is not a bug that will be fixed in a model update. It is a fundamental property of how text generation models work. The defensive handling must be in the calling code.
In production (ScamCheck, ScamCheck historical data):
Before pre-parse cleaning was implemented, ~6% of all analysis requests resulted in a failed response. After cleaning, the parse success rate on real inputs reached 100% across the tested dataset.
☐ Embed exact JSON schema in the prompt (not just a description)
☐ Explicitly instruct: "Return ONLY the JSON object — no code fences, no text before or after"
☐ Pre-parse cleaning: strip markdown code fences before JSON.parse()
☐ Pre-parse cleaning: extract first { to last } substring
☐ JSON.parse() wrapped in try/catch — never let it throw uncaught
☐ Return { parseError: true } as HTTP 200 structured response (not 500)
☐ Client checks parseError flag and renders retry UI
☐ finally { setLoading(false) } in all client paths — no permanent spinners
☐ Log rawPreview on parse failure for diagnosing new failure patterns
☐ Schema validation on parsed output (check required fields exist and have correct types)